Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
306 lines
10 KiB
Go
306 lines
10 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"chat-switchboard/models"
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
type MessageStore struct{}
|
|
|
|
func NewMessageStore() *MessageStore { return &MessageStore{} }
|
|
|
|
func (s *MessageStore) Create(ctx context.Context, m *models.Message) error {
|
|
toolCallsJSON := ToJSON(m.ToolCalls)
|
|
metadataJSON := ToJSON(m.Metadata)
|
|
return DB.QueryRowContext(ctx, `
|
|
INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls,
|
|
metadata, parent_id, sibling_index, participant_type, participant_id)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
RETURNING id, created_at`,
|
|
m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
|
|
toolCallsJSON, metadataJSON,
|
|
models.NullString(m.ParentID), m.SiblingIndex,
|
|
m.ParticipantType, m.ParticipantID,
|
|
).Scan(&m.ID, &m.CreatedAt)
|
|
}
|
|
|
|
func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) {
|
|
var m models.Message
|
|
var parentID sql.NullString
|
|
var toolCallsJSON, metadataJSON []byte
|
|
var deletedAt sql.NullTime
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
FROM messages WHERE id = $1`, id).Scan(
|
|
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
&toolCallsJSON, &metadataJSON,
|
|
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
&deletedAt, &m.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.ParentID = NullableStringPtr(parentID)
|
|
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
|
json.Unmarshal(metadataJSON, &m.Metadata)
|
|
if deletedAt.Valid {
|
|
m.DeletedAt = &deletedAt.Time
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
|
|
b := NewUpdate("messages")
|
|
for k, v := range fields {
|
|
if k == "tool_calls" || k == "metadata" {
|
|
b.SetJSON(k, v)
|
|
} else {
|
|
b.Set(k, v)
|
|
}
|
|
}
|
|
if !b.HasSets() {
|
|
return nil
|
|
}
|
|
b.Where("id", id)
|
|
_, err := b.Exec(DB)
|
|
return err
|
|
}
|
|
|
|
func (s *MessageStore) Delete(ctx context.Context, id string) error {
|
|
now := time.Now()
|
|
_, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = $1 WHERE id = $2", now, id)
|
|
return err
|
|
}
|
|
|
|
func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) {
|
|
b := NewSelect(
|
|
"id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at",
|
|
"messages",
|
|
).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL")
|
|
if opts.Sort == "" {
|
|
b.OrderBy("created_at", "ASC")
|
|
}
|
|
b.Paginate(opts)
|
|
|
|
q, args := b.Build()
|
|
rows, err := DB.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanMessages(rows)
|
|
}
|
|
|
|
func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
FROM messages WHERE parent_id = $1 AND deleted_at IS NULL
|
|
ORDER BY sibling_index`, parentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanMessages(rows)
|
|
}
|
|
|
|
func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
FROM messages
|
|
WHERE parent_id = (SELECT parent_id FROM messages WHERE id = $1)
|
|
AND deleted_at IS NULL
|
|
ORDER BY sibling_index`, messageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanMessages(rows)
|
|
}
|
|
|
|
func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
WITH RECURSIVE path AS (
|
|
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
FROM messages WHERE id = $1
|
|
UNION ALL
|
|
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
|
|
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
|
|
FROM messages m JOIN path p ON m.id = p.parent_id
|
|
)
|
|
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
|
|
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
|
|
FROM path ORDER BY created_at ASC`, messageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanMessages(rows)
|
|
}
|
|
|
|
func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) {
|
|
var maxIdx sql.NullInt64
|
|
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, err
|
|
}
|
|
return int(maxIdx.Int64) + 1, nil
|
|
}
|
|
|
|
func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL",
|
|
channelID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func scanMessages(rows *sql.Rows) ([]models.Message, error) {
|
|
var result []models.Message
|
|
for rows.Next() {
|
|
var m models.Message
|
|
var parentID sql.NullString
|
|
var toolCallsJSON, metadataJSON []byte
|
|
var deletedAt sql.NullTime
|
|
err := rows.Scan(
|
|
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
|
&toolCallsJSON, &metadataJSON,
|
|
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
&deletedAt, &m.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.ParentID = NullableStringPtr(parentID)
|
|
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
|
|
json.Unmarshal(metadataJSON, &m.Metadata)
|
|
if deletedAt.Valid {
|
|
m.DeletedAt = &deletedAt.Time
|
|
}
|
|
result = append(result, m)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
|
|
|
|
func (s *MessageStore) CountAll(ctx context.Context) (int, error) {
|
|
var count int
|
|
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// ── CS5c additions (v0.29.0) ─────────────────────────────────────────────
|
|
|
|
func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) {
|
|
roleClause := ""
|
|
queryArgs := []interface{}{channelID, query, limit}
|
|
if roleFilter == "user" || roleFilter == "assistant" {
|
|
roleClause = "AND m.role = $4"
|
|
queryArgs = append(queryArgs, roleFilter)
|
|
}
|
|
|
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT m.id, m.role,
|
|
ts_headline('english', m.content, plainto_tsquery('english', $2),
|
|
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline,
|
|
ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank,
|
|
m.created_at
|
|
FROM messages m
|
|
WHERE m.channel_id = $1
|
|
AND m.deleted_at IS NULL
|
|
AND m.role IN ('user', 'assistant')
|
|
AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2)
|
|
%s
|
|
ORDER BY rank DESC, m.created_at DESC
|
|
LIMIT $3
|
|
`, roleClause), queryArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
results := make([]store.ChannelSearchResult, 0)
|
|
for rows.Next() {
|
|
var r store.ChannelSearchResult
|
|
if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &r.Rank, &r.Timestamp); err != nil {
|
|
continue
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
return results, rows.Err()
|
|
}
|
|
|
|
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
|
|
|
|
func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) {
|
|
var total int
|
|
err := DB.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`,
|
|
channelID).Scan(&total)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
|
|
m.sibling_index, m.participant_type, m.participant_id,
|
|
CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
|
|
WHEN m.participant_type = 'persona' THEN p.name
|
|
ELSE NULL END AS sender_name,
|
|
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
|
|
WHEN m.participant_type = 'persona' THEN p.avatar
|
|
ELSE NULL END AS sender_avatar,
|
|
m.created_at
|
|
FROM messages m
|
|
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
|
|
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
|
|
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
|
|
ORDER BY m.created_at ASC
|
|
LIMIT $2 OFFSET $3
|
|
`, channelID, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
results := make([]store.MessageWithSender, 0)
|
|
for rows.Next() {
|
|
var m store.MessageWithSender
|
|
if err := rows.Scan(
|
|
&m.ID, &m.ChannelID, &m.Role, &m.Content,
|
|
&m.Model, &m.TokensUsed, &m.ParentID,
|
|
&m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
|
|
&m.SenderName, &m.SenderAvatar,
|
|
&m.CreatedAt,
|
|
); err != nil {
|
|
return nil, total, err
|
|
}
|
|
results = append(results, m)
|
|
}
|
|
return results, total, rows.Err()
|
|
}
|
|
|
|
func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) {
|
|
var parentID sql.NullString
|
|
var role string
|
|
err := DB.QueryRowContext(ctx, `
|
|
SELECT parent_id, role FROM messages
|
|
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
|
`, messageID, channelID).Scan(&parentID, &role)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return NullableStringPtr(parentID), role, nil
|
|
} |