164 lines
5.3 KiB
Go
164 lines
5.3 KiB
Go
package treepath
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"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"`
|
|
ToolCalls *json.RawMessage `json:"tool_calls,omitempty"`
|
|
Metadata *json.RawMessage `json:"metadata,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(database.Q(`
|
|
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(database.Q(`
|
|
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(database.Q(`
|
|
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(database.Q(`
|
|
WITH RECURSIVE path AS (
|
|
-- Anchor: start at the leaf
|
|
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
|
|
|
|
-- Walk up via parent_id
|
|
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 []PathMessage
|
|
for rows.Next() {
|
|
var m 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)
|
|
}
|
|
|
|
// Enrich with sibling counts
|
|
for i := range path {
|
|
path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID)
|
|
}
|
|
|
|
return path, nil
|
|
}
|