384 lines
11 KiB
Go
384 lines
11 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"chat-switchboard/models"
|
|
"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
|
|
}
|