Changeset 0.29.0 (#195)
This commit is contained in:
382
server/store/sqlite/message_tree.go
Normal file
382
server/store/sqlite/message_tree.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────
|
||||
|
||||
func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) {
|
||||
var leafID *string
|
||||
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT active_leaf_id FROM channel_cursors
|
||||
WHERE channel_id = ? AND user_id = ?
|
||||
`, channelID, userID).Scan(&leafID)
|
||||
|
||||
if err == nil && leafID != nil {
|
||||
var exists bool
|
||||
DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM messages WHERE id = ? AND deleted_at IS NULL)
|
||||
`, *leafID).Scan(&exists)
|
||||
if exists {
|
||||
return leafID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackID string
|
||||
err = DB.QueryRowContext(ctx, `
|
||||
SELECT id FROM messages
|
||||
WHERE channel_id = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, channelID).Scan(&fallbackID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
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 = ? AND channel_id = ? 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
|
||||
}
|
||||
|
||||
for i := range path {
|
||||
count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID)
|
||||
path[i].SiblingCount = count
|
||||
}
|
||||
|
||||
_ = 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) {
|
||||
var parentID *string
|
||||
var channelID string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT parent_id, channel_id FROM messages
|
||||
WHERE id = ? 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 = ? 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 = ? 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 = ? 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 = ? AND deleted_at IS NULL
|
||||
`, *parentID).Scan(&count)
|
||||
}
|
||||
if err != nil || count == 0 {
|
||||
return 1, nil
|
||||
}
|
||||
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 = ? 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
|
||||
}
|
||||
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 = ? 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 = ? 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 = ? 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 {
|
||||
m.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
m.CreatedAt = now
|
||||
|
||||
// tool_calls: serialize for SQLite
|
||||
var tcVal interface{}
|
||||
if m.ToolCalls != nil {
|
||||
tcVal = ToJSON(m.ToolCalls)
|
||||
}
|
||||
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tokens_used,
|
||||
tool_calls, parent_id, participant_type, participant_id,
|
||||
provider_config_id, sibling_index, created_at)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)
|
||||
`, m.ID, m.ChannelID, m.Role, m.Content, safeModelStr(m.Model), m.TokensUsed,
|
||||
tcVal, models.NullString(m.ParentID),
|
||||
m.ParticipantType, m.ParticipantID,
|
||||
safeStringPtr(m.ProviderConfigID), m.SiblingIndex,
|
||||
now.Format(timeFmt),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateWithCursor insert: %w", err)
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
if cursorUserID != "" {
|
||||
_, _ = DB.ExecContext(ctx, `
|
||||
INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||
active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')
|
||||
`, store.NewID(), m.ChannelID, cursorUserID, m.ID)
|
||||
}
|
||||
|
||||
// Touch channel
|
||||
_, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, m.ChannelID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 = ?
|
||||
`, uid).Scan(&name, &avatar)
|
||||
if name.Valid {
|
||||
userNames[uid] = name.String
|
||||
}
|
||||
if avatar.Valid {
|
||||
userAvatars[uid] = avatar.String
|
||||
}
|
||||
}
|
||||
|
||||
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 = ?
|
||||
`, pid).Scan(&name, &avatar)
|
||||
if name.Valid {
|
||||
personaNames[pid] = name.String
|
||||
}
|
||||
if avatar.Valid {
|
||||
personaAvatars[pid] = avatar.String
|
||||
}
|
||||
}
|
||||
|
||||
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 safeModelStr(m string) string {
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func safeStringPtr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
Reference in New Issue
Block a user