Changeset 0.29.0 (#195)
This commit is contained in:
383
server/store/postgres/message_tree.go
Normal file
383
server/store/postgres/message_tree.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────
|
||||
// Moved from treepath package. All message tree traversal goes through
|
||||
// the store interface now.
|
||||
|
||||
func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) {
|
||||
var leafID *string
|
||||
|
||||
// Try cursor first
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
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
|
||||
DB.QueryRowContext(ctx, `
|
||||
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 = DB.QueryRowContext(ctx, `
|
||||
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
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE path AS (
|
||||
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
|
||||
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
|
||||
|
||||
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
||||
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, tool_calls, metadata,
|
||||
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 []store.PathMessage
|
||||
for rows.Next() {
|
||||
var m store.PathMessage
|
||||
var participantType, participantID sql.NullString
|
||||
var toolCallsJSON, metadataJSON []byte
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
||||
&toolCallsJSON, &metadataJSON,
|
||||
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("GetPathToLeaf scan: %w", err)
|
||||
}
|
||||
if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" {
|
||||
raw := json.RawMessage(toolCallsJSON)
|
||||
m.ToolCalls = &raw
|
||||
}
|
||||
if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" {
|
||||
raw := json.RawMessage(metadataJSON)
|
||||
m.Metadata = &raw
|
||||
}
|
||||
if participantType.Valid {
|
||||
m.ParticipantType = participantType.String
|
||||
}
|
||||
if participantID.Valid {
|
||||
m.ParticipantID = participantID.String
|
||||
}
|
||||
path = append(path, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Enrich with sibling counts
|
||||
for i := range path {
|
||||
count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID)
|
||||
path[i].SiblingCount = count
|
||||
}
|
||||
|
||||
// Resolve sender info
|
||||
_ = s.ResolveSenderInfo(ctx, path)
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) {
|
||||
leafID, err := s.GetActiveLeaf(ctx, channelID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if leafID == nil {
|
||||
return []store.PathMessage{}, nil
|
||||
}
|
||||
return s.GetPathToLeaf(ctx, channelID, *leafID)
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) {
|
||||
// Get parent_id and channel_id of the target message
|
||||
var parentID *string
|
||||
var channelID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
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 {
|
||||
rows, err = DB.QueryContext(ctx, `
|
||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 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 = DB.QueryContext(ctx, `
|
||||
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 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 []store.SiblingInfo
|
||||
currentIdx := 0
|
||||
for i := 0; rows.Next(); i++ {
|
||||
var si store.SiblingInfo
|
||||
if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if si.ID == messageID {
|
||||
currentIdx = i
|
||||
}
|
||||
siblings = append(siblings, si)
|
||||
}
|
||||
|
||||
return siblings, currentIdx, rows.Err()
|
||||
}
|
||||
|
||||
func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if parentID == nil {
|
||||
err = DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
`, channelID).Scan(&count)
|
||||
} else {
|
||||
err = DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&count)
|
||||
}
|
||||
if err != nil || count == 0 {
|
||||
return 1, nil // minimum 1 (the message itself)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) {
|
||||
var leafID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
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 // fallback to message itself
|
||||
}
|
||||
return leafID, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) {
|
||||
var maxIdx sql.NullInt64
|
||||
var err error
|
||||
if parentID == nil {
|
||||
err = DB.QueryRowContext(ctx, `
|
||||
SELECT MAX(sibling_index) FROM messages
|
||||
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||
`, channelID).Scan(&maxIdx)
|
||||
} else {
|
||||
err = DB.QueryRowContext(ctx, `
|
||||
SELECT MAX(sibling_index) FROM messages
|
||||
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&maxIdx)
|
||||
}
|
||||
if err != nil || !maxIdx.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return int(maxIdx.Int64) + 1, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) {
|
||||
var id string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id FROM messages
|
||||
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
|
||||
LIMIT 1
|
||||
`, channelID).Scan(&id)
|
||||
return err == nil && id != "", nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error {
|
||||
// Insert message — PG generates ID via gen_random_uuid()
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id,
|
||||
provider_config_id, sibling_index)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, created_at`,
|
||||
m.ChannelID, m.Role, m.Content, safeModel(m.Model), m.TokensUsed,
|
||||
ToJSON(m.ToolCalls), models.NullString(m.ParentID),
|
||||
m.ParticipantType, m.ParticipantID,
|
||||
safeString(m.ProviderConfigID), m.SiblingIndex,
|
||||
).Scan(&m.ID, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateWithCursor insert: %w", err)
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
if cursorUserID != "" {
|
||||
_, _ = DB.ExecContext(ctx, `
|
||||
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()
|
||||
`, m.ChannelID, cursorUserID, m.ID)
|
||||
}
|
||||
|
||||
// Touch channel
|
||||
_, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, m.ChannelID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error {
|
||||
// Collect unique participant IDs by type
|
||||
userIDs := map[string]bool{}
|
||||
personaIDs := map[string]bool{}
|
||||
for _, m := range path {
|
||||
if m.ParticipantID == "" {
|
||||
continue
|
||||
}
|
||||
switch m.ParticipantType {
|
||||
case "user":
|
||||
userIDs[m.ParticipantID] = true
|
||||
case "persona":
|
||||
personaIDs[m.ParticipantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve users
|
||||
userNames := map[string]string{}
|
||||
userAvatars := map[string]string{}
|
||||
for uid := range userIDs {
|
||||
var name, avatar sql.NullString
|
||||
_ = DB.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
|
||||
`, uid).Scan(&name, &avatar)
|
||||
if name.Valid {
|
||||
userNames[uid] = name.String
|
||||
}
|
||||
if avatar.Valid {
|
||||
userAvatars[uid] = avatar.String
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve personas
|
||||
personaNames := map[string]string{}
|
||||
personaAvatars := map[string]string{}
|
||||
for pid := range personaIDs {
|
||||
var name, avatar sql.NullString
|
||||
_ = DB.QueryRowContext(ctx, `
|
||||
SELECT name, avatar FROM personas WHERE id = $1
|
||||
`, pid).Scan(&name, &avatar)
|
||||
if name.Valid {
|
||||
personaNames[pid] = name.String
|
||||
}
|
||||
if avatar.Valid {
|
||||
personaAvatars[pid] = avatar.String
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to path
|
||||
for i := range path {
|
||||
pid := path[i].ParticipantID
|
||||
switch path[i].ParticipantType {
|
||||
case "user":
|
||||
if n, ok := userNames[pid]; ok {
|
||||
path[i].SenderName = &n
|
||||
}
|
||||
if a, ok := userAvatars[pid]; ok && a != "" {
|
||||
path[i].SenderAvatar = &a
|
||||
}
|
||||
case "persona":
|
||||
if n, ok := personaNames[pid]; ok {
|
||||
path[i].SenderName = &n
|
||||
}
|
||||
if a, ok := personaAvatars[pid]; ok && a != "" {
|
||||
path[i].SenderAvatar = &a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────
|
||||
|
||||
func safeModel(m string) interface{} {
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func safeString(s *string) interface{} {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
Reference in New Issue
Block a user