Changeset 0.15.0 (#71)
This commit is contained in:
39
server/treepath/cursor.go
Normal file
39
server/treepath/cursor.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package treepath
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
163
server/treepath/path.go
Normal file
163
server/treepath/path.go
Normal file
@@ -0,0 +1,163 @@
|
||||
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(`
|
||||
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, 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
|
||||
}
|
||||
114
server/treepath/siblings.go
Normal file
114
server/treepath/siblings.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package treepath
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
38
server/treepath/summary.go
Normal file
38
server/treepath/summary.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package treepath
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// IsSummaryMessage checks if a PathMessage has summary metadata
|
||||
// (metadata.type == "summary").
|
||||
func IsSummaryMessage(m *PathMessage) bool {
|
||||
if m.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
meta := ParseMetadata(m)
|
||||
return meta["type"] == "summary"
|
||||
}
|
||||
|
||||
// ParseMetadata unmarshals the JSONB metadata on a PathMessage.
|
||||
// Returns an empty map on any error.
|
||||
func ParseMetadata(m *PathMessage) map[string]interface{} {
|
||||
if m.Metadata == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var meta map[string]interface{}
|
||||
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// FindSummaryBoundary returns the index of the most recent summary node
|
||||
// in a path, or -1 if no summary exists.
|
||||
func FindSummaryBoundary(path []PathMessage) int {
|
||||
idx := -1
|
||||
for i := range path {
|
||||
if IsSummaryMessage(&path[i]) {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
Reference in New Issue
Block a user