Changeset 0.8.0 (#39)
This commit is contained in:
@@ -128,20 +128,13 @@ func TestCreateMessageValidRoles(t *testing.T) {
|
||||
t.Skip("requires database connection for ownership check")
|
||||
}
|
||||
|
||||
// ── Regenerate Stub ─────────────────────────
|
||||
// ── Regenerate ───────────────────────────────
|
||||
|
||||
func TestRegenerateReturns501(t *testing.T) {
|
||||
h := NewMessageHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/x/regenerate", nil)
|
||||
|
||||
h.Regenerate(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Errorf("Expected 501, got %d", w.Code)
|
||||
}
|
||||
func TestRegenerateRequiresDB(t *testing.T) {
|
||||
// Regenerate is now a full handler (creates sibling assistant message
|
||||
// via tree-aware streaming). It calls userOwnsChannel → database.DB,
|
||||
// so it cannot run without a live database connection.
|
||||
t.Skip("requires database connection for ownership check")
|
||||
}
|
||||
|
||||
// ── Pagination Helpers ──────────────────────
|
||||
|
||||
@@ -113,7 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, presetSystemPrompt)
|
||||
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -126,7 +126,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
})
|
||||
|
||||
// Persist user message
|
||||
if err := h.persistMessage(channelID, "user", req.Content, "", 0, 0); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist user message: %v", err)
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
if stream {
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, model)
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
|
||||
} else {
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, model)
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, model string,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
@@ -228,7 +228,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
|
||||
// Persist assistant response
|
||||
if fullContent != "" {
|
||||
if err := h.persistMessage(channelID, "assistant", fullContent, model, 0, 0); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, model string,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
@@ -250,7 +250,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
}
|
||||
|
||||
// Persist assistant response
|
||||
if err := h.persistMessage(channelID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
@@ -394,7 +394,10 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
|
||||
// ── Conversation Loader ─────────────────────
|
||||
|
||||
func (h *CompletionHandler) loadConversation(channelID string, presetSystemPrompt string) ([]providers.Message, error) {
|
||||
// loadConversation builds the message history for the LLM by walking the
|
||||
// active path through the message tree. Only the current branch is sent —
|
||||
// sibling branches are never included in context.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
||||
// Load system prompt from channel
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(
|
||||
@@ -416,23 +419,20 @@ func (h *CompletionHandler) loadConversation(channelID string, presetSystemPromp
|
||||
})
|
||||
}
|
||||
|
||||
// Load message history (oldest first)
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT role, content FROM messages
|
||||
WHERE channel_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, channelID)
|
||||
// Walk the active path (root → leaf) instead of loading all messages
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var msg providers.Message
|
||||
if err := rows.Scan(&msg.Role, &msg.Content); err != nil {
|
||||
return nil, err
|
||||
for _, m := range path {
|
||||
if m.Role == "system" {
|
||||
continue // system prompts handled above
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
messages = append(messages, providers.Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
@@ -440,42 +440,59 @@ func (h *CompletionHandler) loadConversation(channelID string, presetSystemPromp
|
||||
|
||||
// ── Message Persistence ─────────────────────
|
||||
|
||||
func (h *CompletionHandler) persistMessage(channelID, role, content, model string, inputTokens, outputTokens int) error {
|
||||
// persistMessage inserts a message into the tree, updates the cursor, and
|
||||
// returns the new message's ID.
|
||||
//
|
||||
// Parent resolution:
|
||||
// - parentOverride (explicit parent for edit/regen operations)
|
||||
// - cursor's active_leaf_id (normal conversation flow)
|
||||
// - latest message by created_at (fallback for legacy/missing cursor)
|
||||
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string) (string, error) {
|
||||
var tokensUsed *int
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
total := inputTokens + outputTokens
|
||||
tokensUsed = &total
|
||||
}
|
||||
|
||||
// Find current leaf for parent_id
|
||||
// Determine parent
|
||||
var parentID *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||
channelID,
|
||||
).Scan(&parentID)
|
||||
if parentOverride != nil {
|
||||
parentID = parentOverride
|
||||
} else {
|
||||
parentID, _ = getActiveLeaf(channelID, userID)
|
||||
}
|
||||
|
||||
// Compute sibling_index
|
||||
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||
|
||||
// Determine participant
|
||||
participantType := "user"
|
||||
participantID := channelID // will be overridden below
|
||||
participantID := userID
|
||||
if role == "assistant" {
|
||||
participantType = "model"
|
||||
participantID = model
|
||||
} else {
|
||||
// Get channel owner as participant_id for user messages
|
||||
var ownerID string
|
||||
if err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID); err == nil {
|
||||
participantID = ownerID
|
||||
}
|
||||
}
|
||||
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8)
|
||||
`, channelID, role, content, model, tokensUsed, parentID, participantType, participantID)
|
||||
// Insert with RETURNING id
|
||||
var newID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`, channelID, role, content, model, tokensUsed,
|
||||
parentID, participantType, participantID, siblingIdx,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Update cursor to point at new message
|
||||
if userID != "" {
|
||||
_ = updateCursor(channelID, userID, newID)
|
||||
}
|
||||
|
||||
// Touch channel updated_at
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
return nil
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
// ── Request / Response types ────────────────
|
||||
@@ -19,14 +24,32 @@ 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"`
|
||||
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"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type editRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
type regenerateRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
APIConfigID string `json:"api_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
type cursorRequest struct {
|
||||
ActiveLeafID string `json:"active_leaf_id" binding:"required"`
|
||||
}
|
||||
|
||||
// MessageHandler holds dependencies for message endpoints.
|
||||
@@ -37,7 +60,11 @@ func NewMessageHandler() *MessageHandler {
|
||||
return &MessageHandler{}
|
||||
}
|
||||
|
||||
// ── List Messages ───────────────────────────
|
||||
// ── List Messages (flat, all branches) ──────
|
||||
// GET /channels/:id/messages
|
||||
//
|
||||
// Returns all live messages across all branches (useful for admin/debug).
|
||||
// Frontend should prefer /channels/:id/path for rendering.
|
||||
|
||||
func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
page, perPage, offset := parsePagination(c)
|
||||
@@ -45,15 +72,13 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Count total messages
|
||||
var total int
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT COUNT(*) FROM messages WHERE channel_id = $1`,
|
||||
`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`,
|
||||
channelID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
@@ -61,11 +86,11 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch messages (oldest first for conversation display)
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages
|
||||
WHERE channel_id = $1
|
||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, channelID, perPage, offset)
|
||||
@@ -80,12 +105,14 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
var msg messageResponse
|
||||
err := rows.Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
||||
return
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
@@ -98,7 +125,32 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Create Message ──────────────────────────
|
||||
// ── Active Path ─────────────────────────────
|
||||
// GET /channels/:id/path
|
||||
//
|
||||
// Returns the active branch from root → leaf with sibling metadata
|
||||
// at each node. This is what the frontend renders.
|
||||
|
||||
func (h *MessageHandler) GetActivePath(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
path, err := getActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
log.Printf("GetActivePath error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"path": path})
|
||||
}
|
||||
|
||||
// ── Create Message (manual) ─────────────────
|
||||
// POST /channels/:id/messages
|
||||
|
||||
func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||
var req createMessageRequest
|
||||
@@ -110,64 +162,457 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the current leaf message to set as parent
|
||||
var parentID *string
|
||||
_ = database.DB.QueryRow(
|
||||
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||
channelID,
|
||||
).Scan(&parentID)
|
||||
// Use cursor for parent, compute sibling_index
|
||||
parentID, _ := getActiveLeaf(channelID, userID)
|
||||
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||
|
||||
participantType := "user"
|
||||
participantID := userID
|
||||
if req.Role == "assistant" {
|
||||
participantType = "model"
|
||||
if req.Model != "" {
|
||||
participantID = req.Model
|
||||
}
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at
|
||||
INSERT INTO messages (channel_id, role, content, model, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
func() string {
|
||||
if req.Role == "assistant" {
|
||||
return "model"
|
||||
}
|
||||
return "user"
|
||||
}(),
|
||||
func() string {
|
||||
if req.Role == "assistant" && req.Model != "" {
|
||||
return req.Model
|
||||
}
|
||||
return userID
|
||||
}(),
|
||||
participantType, participantID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
|
||||
// Touch the channel's updated_at so it sorts to top of list
|
||||
_ = updateCursor(channelID, userID, msg.ID)
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
// ── Regenerate (stub for #8 Chat Engine) ────
|
||||
// ── Edit Message (create sibling) ───────────
|
||||
// POST /channels/:id/messages/:msgId/edit
|
||||
//
|
||||
// Creates a new user message as a sibling of the target (same parent_id).
|
||||
// Does NOT auto-trigger completion — the frontend sends a separate request.
|
||||
|
||||
func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "regenerate requires Chat Engine (ticket #8)",
|
||||
})
|
||||
func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
var req editRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
messageID := c.Param("msgId")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load target — must exist, belong to channel, be a user message
|
||||
var targetParentID *string
|
||||
var targetRole string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT parent_id, role FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
`, messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
|
||||
return
|
||||
}
|
||||
if targetRole != "user" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create sibling: same parent_id as the target
|
||||
siblingIdx := nextSiblingIndex(channelID, targetParentID)
|
||||
|
||||
var msg messageResponse
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'user', $2, $3, 'user', $4, $5)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Content, targetParentID, userID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
|
||||
return
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
|
||||
// Cursor now points to the new sibling (it's a leaf — no children yet)
|
||||
_ = updateCursor(channelID, userID, msg.ID)
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
// ── Stream (stub for #8 Chat Engine) ────────
|
||||
// ── Regenerate / Complete ──────────────────────
|
||||
// POST /channels/:id/messages/:msgId/regenerate
|
||||
//
|
||||
// Two modes depending on target role:
|
||||
// - assistant message → creates a NEW assistant sibling (same parent_id).
|
||||
// Context is root → target's parent. ("Give me a different response.")
|
||||
// - user message → creates a child assistant response.
|
||||
// Context is root → target. ("Respond to this message.")
|
||||
//
|
||||
// The second mode is used after editing: the edit endpoint creates a
|
||||
// sibling user message, then the frontend calls regenerate on it to
|
||||
// get an assistant response without duplicating the user message.
|
||||
|
||||
func (h *MessageHandler) Stream(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "streaming requires Chat Engine (ticket #8)",
|
||||
func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
var req regenerateRequest
|
||||
_ = c.ShouldBindJSON(&req) // body is optional
|
||||
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
messageID := c.Param("msgId")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load target message
|
||||
var targetParentID *string
|
||||
var targetRole string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT parent_id, role FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
`, messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
|
||||
return
|
||||
}
|
||||
if targetRole != "assistant" && targetRole != "user" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine context path and new message's parent based on target role:
|
||||
//
|
||||
// assistant target: context = root → target's parent (exclude target)
|
||||
// new parent = target's parent (sibling of target)
|
||||
//
|
||||
// user target: context = root → target (include target)
|
||||
// new parent = target itself (child of target)
|
||||
var contextPath []PathMessage
|
||||
var newParentID *string
|
||||
|
||||
if targetRole == "assistant" {
|
||||
if targetParentID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"})
|
||||
return
|
||||
}
|
||||
contextPath, err = getPathToLeaf(channelID, *targetParentID)
|
||||
newParentID = targetParentID
|
||||
} else {
|
||||
// user message — respond to it
|
||||
contextPath, err = getPathToLeaf(channelID, messageID)
|
||||
newParentID = &messageID
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Resolve model + provider ──
|
||||
|
||||
comp := NewCompletionHandler()
|
||||
|
||||
var presetSystemPrompt string
|
||||
model := req.Model
|
||||
apiConfigID := req.APIConfigID
|
||||
temperature := req.Temperature
|
||||
maxTokens := req.MaxTokens
|
||||
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
||||
return
|
||||
}
|
||||
if model == "" {
|
||||
model = preset.BaseModelID
|
||||
}
|
||||
if apiConfigID == "" && preset.APIConfigID != nil {
|
||||
apiConfigID = *preset.APIConfigID
|
||||
}
|
||||
if temperature == nil && preset.Temperature != nil {
|
||||
temperature = preset.Temperature
|
||||
}
|
||||
if maxTokens == 0 && preset.MaxTokens != nil {
|
||||
maxTokens = *preset.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: channel's stored model
|
||||
if model == "" {
|
||||
var channelModel *string
|
||||
_ = database.DB.QueryRow(`SELECT model FROM channels WHERE id = $1`, channelID).Scan(&channelModel)
|
||||
if channelModel != nil {
|
||||
model = *channelModel
|
||||
}
|
||||
}
|
||||
|
||||
providerCfg, providerID, model, configID, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||
Model: model,
|
||||
APIConfigID: apiConfigID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build LLM message array
|
||||
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
|
||||
|
||||
if presetSystemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt})
|
||||
} else {
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(`SELECT system_prompt FROM channels WHERE id = $1`, channelID).Scan(&systemPrompt)
|
||||
if systemPrompt != nil && *systemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range contextPath {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
provReq := providers.CompletionRequest{
|
||||
Model: model,
|
||||
Messages: llmMessages,
|
||||
}
|
||||
|
||||
caps := comp.getModelCapabilities(model, configID)
|
||||
if maxTokens > 0 {
|
||||
provReq.MaxTokens = maxTokens
|
||||
} else {
|
||||
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
|
||||
}
|
||||
if temperature != nil {
|
||||
provReq.Temperature = temperature
|
||||
}
|
||||
|
||||
// ── Stream the response ──
|
||||
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), providerCfg, provReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
var fullContent string
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
for event := range ch {
|
||||
if event.Error != nil {
|
||||
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if event.Delta != "" {
|
||||
fullContent += event.Delta
|
||||
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n",
|
||||
event.Delta, model)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n",
|
||||
finishReason, model)
|
||||
io.WriteString(c.Writer, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Persist as child or sibling depending on target role
|
||||
if fullContent != "" {
|
||||
siblingIdx := nextSiblingIndex(channelID, newParentID)
|
||||
|
||||
var newID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'assistant', $2, $3, $4, 'model', $5, $6)
|
||||
RETURNING id
|
||||
`, channelID, fullContent, model, newParentID, model, siblingIdx).Scan(&newID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
} else {
|
||||
_ = updateCursor(channelID, userID, newID)
|
||||
|
||||
// Send message metadata in a final SSE event so frontend can update
|
||||
msgJSON, _ := json.Marshal(gin.H{
|
||||
"id": newID,
|
||||
"parent_id": newParentID,
|
||||
"sibling_index": siblingIdx,
|
||||
"sibling_count": getSiblingCount(channelID, newParentID),
|
||||
})
|
||||
fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Switch Branch (update cursor) ───────────
|
||||
// PUT /channels/:id/cursor
|
||||
//
|
||||
// Navigates to a different branch. If the target message has children,
|
||||
// follows the first-child chain to find the leaf.
|
||||
|
||||
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
||||
var req cursorRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the target message belongs to this channel
|
||||
var msgChannelID string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT channel_id FROM messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, req.ActiveLeafID).Scan(&msgChannelID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
|
||||
return
|
||||
}
|
||||
if msgChannelID != channelID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "message does not belong to this channel"})
|
||||
return
|
||||
}
|
||||
|
||||
// Walk down to the leaf from the target
|
||||
leafID, err := findLeafFromMessage(req.ActiveLeafID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateCursor(channelID, userID, leafID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the new active path
|
||||
path, err := getPathToLeaf(channelID, leafID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"path": path, "active_leaf_id": leafID})
|
||||
}
|
||||
|
||||
// ── List Siblings ───────────────────────────
|
||||
// GET /channels/:id/messages/:msgId/siblings
|
||||
//
|
||||
// Returns all siblings of a message (messages sharing the same parent_id).
|
||||
// Used by the branch navigator to populate the arrow navigation.
|
||||
|
||||
func (h *MessageHandler) ListSiblings(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
messageID := c.Param("msgId")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
siblings, currentIdx, err := getSiblings(messageID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"siblings": siblings,
|
||||
"current_index": currentIdx,
|
||||
"total": len(siblings),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
296
server/handlers/tree.go
Normal file
296
server/handlers/tree.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Tree Types ──────────────────────────────
|
||||
|
||||
// PathMessage represents a single message in an active path, enriched
|
||||
// with sibling metadata for the frontend branch indicator.
|
||||
type PathMessage struct {
|
||||
ID string `json:"id"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Model *string `json:"model"`
|
||||
TokensUsed *int `json:"tokens_used,omitempty"`
|
||||
SiblingCount int `json:"sibling_count"`
|
||||
SiblingIndex int `json:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty"`
|
||||
ParticipantID string `json:"participant_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// SiblingInfo is a lightweight entry for the siblings listing endpoint.
|
||||
type SiblingInfo struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SiblingIndex int `json:"sibling_index"`
|
||||
Preview string `json:"preview"` // first ~80 chars of content
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Get Active Leaf ─────────────────────────
|
||||
|
||||
// getActiveLeaf returns the cursor's active_leaf_id for a user in a channel.
|
||||
// Falls back to the chronologically latest live message if no cursor exists.
|
||||
func getActiveLeaf(channelID, userID string) (*string, error) {
|
||||
var leafID *string
|
||||
|
||||
// Try cursor first
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT active_leaf_id FROM channel_cursors
|
||||
WHERE channel_id = $1 AND user_id = $2
|
||||
`, channelID, userID).Scan(&leafID)
|
||||
|
||||
if err == nil && leafID != nil {
|
||||
// Verify the leaf still exists and isn't deleted
|
||||
var exists bool
|
||||
database.DB.QueryRow(`
|
||||
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL)
|
||||
`, *leafID).Scan(&exists)
|
||||
if exists {
|
||||
return leafID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: latest live message in channel
|
||||
var fallbackID string
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id FROM messages
|
||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, channelID).Scan(&fallbackID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // empty channel
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fallbackID, nil
|
||||
}
|
||||
|
||||
// ── Get Active Path ─────────────────────────
|
||||
|
||||
// getActivePath walks from the active leaf up to the root via parent_id,
|
||||
// returning messages in root-first order. This is what gets sent to the LLM.
|
||||
func getActivePath(channelID, userID string) ([]PathMessage, error) {
|
||||
leafID, err := getActiveLeaf(channelID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if leafID == nil {
|
||||
return []PathMessage{}, nil // empty channel
|
||||
}
|
||||
|
||||
return getPathToLeaf(channelID, *leafID)
|
||||
}
|
||||
|
||||
// getPathToLeaf walks from a specific leaf up to root, returning root-first.
|
||||
func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
rows, err := database.DB.Query(`
|
||||
WITH RECURSIVE path AS (
|
||||
-- Anchor: start at the leaf
|
||||
SELECT id, parent_id, role, content, model, tokens_used,
|
||||
participant_type, participant_id, sibling_index, created_at,
|
||||
0 AS depth
|
||||
FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Walk up via parent_id
|
||||
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used,
|
||||
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
|
||||
p.depth + 1
|
||||
FROM messages m
|
||||
JOIN path p ON m.id = p.parent_id
|
||||
WHERE m.deleted_at IS NULL
|
||||
)
|
||||
SELECT id, parent_id, role, content, model, tokens_used,
|
||||
participant_type, participant_id, sibling_index, created_at
|
||||
FROM path
|
||||
ORDER BY depth DESC
|
||||
`, leafID, channelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getPathToLeaf: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var path []PathMessage
|
||||
for rows.Next() {
|
||||
var m PathMessage
|
||||
var participantType, participantID sql.NullString
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
||||
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("getPathToLeaf scan: %w", err)
|
||||
}
|
||||
if participantType.Valid {
|
||||
m.ParticipantType = participantType.String
|
||||
}
|
||||
if participantID.Valid {
|
||||
m.ParticipantID = participantID.String
|
||||
}
|
||||
path = append(path, m)
|
||||
}
|
||||
|
||||
// Enrich with sibling counts
|
||||
for i := range path {
|
||||
path[i].SiblingCount = getSiblingCount(channelID, path[i].ParentID)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ── Sibling Queries ─────────────────────────
|
||||
|
||||
// getSiblingCount returns how many live siblings share the same parent_id.
|
||||
// Root messages (parent_id IS NULL) count all roots in the same channel.
|
||||
func getSiblingCount(channelID string, parentID *string) int {
|
||||
var count int
|
||||
if parentID == nil {
|
||||
database.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
`, channelID).Scan(&count)
|
||||
} else {
|
||||
database.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&count)
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// getSiblings returns all live siblings of a given message (including itself),
|
||||
// ordered by sibling_index.
|
||||
func getSiblings(messageID string) ([]SiblingInfo, int, error) {
|
||||
// First get the parent_id of the target message
|
||||
var parentID *string
|
||||
var channelID string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT parent_id, channel_id FROM messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, messageID).Scan(&parentID, &channelID)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("message not found: %w", err)
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
if parentID == nil {
|
||||
// Root messages: siblings are other roots in the same channel
|
||||
rows, err = database.DB.Query(`
|
||||
SELECT id, role, model, sibling_index, LEFT(content, 80), created_at
|
||||
FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
ORDER BY sibling_index, created_at
|
||||
`, channelID)
|
||||
} else {
|
||||
rows, err = database.DB.Query(`
|
||||
SELECT id, role, model, sibling_index, LEFT(content, 80), created_at
|
||||
FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY sibling_index, created_at
|
||||
`, *parentID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var siblings []SiblingInfo
|
||||
currentIdx := 0
|
||||
for i := 0; rows.Next(); i++ {
|
||||
var s SiblingInfo
|
||||
if err := rows.Scan(&s.ID, &s.Role, &s.Model, &s.SiblingIndex, &s.Preview, &s.CreatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if s.ID == messageID {
|
||||
currentIdx = i
|
||||
}
|
||||
siblings = append(siblings, s)
|
||||
}
|
||||
|
||||
return siblings, currentIdx, nil
|
||||
}
|
||||
|
||||
// ── Find Leaf ───────────────────────────────
|
||||
|
||||
// findLeafFromMessage walks down from a message to find the deepest descendant.
|
||||
// Follows the first child (lowest sibling_index) at each level.
|
||||
// Used when switching branches: clicking a mid-tree sibling navigates to its leaf.
|
||||
func findLeafFromMessage(messageID string) (string, error) {
|
||||
var leafID string
|
||||
err := database.DB.QueryRow(`
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id, 0 AS depth
|
||||
FROM messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT child.id, d.depth + 1
|
||||
FROM messages child
|
||||
JOIN descendants d ON child.parent_id = d.id
|
||||
WHERE child.deleted_at IS NULL
|
||||
AND child.sibling_index = (
|
||||
SELECT MIN(sibling_index) FROM messages
|
||||
WHERE parent_id = d.id AND deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
ORDER BY depth DESC
|
||||
LIMIT 1
|
||||
`, messageID).Scan(&leafID)
|
||||
|
||||
if err != nil {
|
||||
return messageID, nil // if anything fails, just use the message itself
|
||||
}
|
||||
return leafID, nil
|
||||
}
|
||||
|
||||
// ── Next Sibling Index ──────────────────────
|
||||
|
||||
// nextSiblingIndex returns the next sibling_index for a new child of parentID.
|
||||
// For root messages (parentID is nil), counts existing roots in the channel.
|
||||
func nextSiblingIndex(channelID string, parentID *string) int {
|
||||
var maxIdx sql.NullInt64
|
||||
if parentID == nil {
|
||||
database.DB.QueryRow(`
|
||||
SELECT MAX(sibling_index) FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
`, channelID).Scan(&maxIdx)
|
||||
} else {
|
||||
database.DB.QueryRow(`
|
||||
SELECT MAX(sibling_index) FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&maxIdx)
|
||||
}
|
||||
if !maxIdx.Valid {
|
||||
return 0
|
||||
}
|
||||
return int(maxIdx.Int64) + 1
|
||||
}
|
||||
|
||||
// ── Update Cursor ───────────────────────────
|
||||
|
||||
// updateCursor upserts the channel_cursors row for a user.
|
||||
func updateCursor(channelID, userID, messageID string) error {
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||
active_leaf_id = $3, updated_at = NOW()
|
||||
`, channelID, userID, messageID)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user