Files
Cyrene_v2/internal/runtime/history/session_preview.go
T
AskaEth d102f75469
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(history): 会话列表lastMessage预览(B3) — 单查询无N+1
- history/session_preview.go: SessionMeta.LastMessage + 单条SQL(LEFT JOIN取每会话ORDER BY created_at DESC,rowid DESC LIMIT 1,走覆盖索引)
- ListSessions 迁出重写(签名不变), content折叠空白截前60rune
- gateway: historySessionSummary 增 lastMessage{role,content,createdAt}(omitempty), /sessions与/admin/sessions共用
- 新增测试(session_preview_test, history_lastmessage_test)
- Docs/api-client.md v0.22 同步
2026-09-08 21:17:26 +08:00

104 lines
3.8 KiB
Go

package history
import (
"context"
"database/sql"
"fmt"
"strings"
)
// This file owns the last-message preview carried in the session list
// (GET /api/v1/sessions): a display-only summary of each session's newest message so a
// client can render a conversation row without one request per session. The preview is
// truncated for display and never feeds identity or permission decisions — identity
// comes from channel actor metadata / JWT (project red line).
// lastMessagePreviewRunes bounds the preview content so a long message cannot bloat the
// session-list response. A longer message is whitespace-collapsed and truncated to this
// many runes with an ellipsis.
const lastMessagePreviewRunes = 60
// MessageSummary is a display-only preview of a session's most recent message.
type MessageSummary struct {
Role string // "user" | "assistant"
Content string // whitespace-collapsed, truncated preview
CreatedAt int64 // Unix milliseconds
}
// listSessionsQuery reads every session plus its newest message in one statement. The
// LEFT JOIN's correlated subquery resolves each session's newest row via the
// (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond tie by
// insertion order), so a session with no messages still appears with NULL preview
// columns. Ordering keeps the main session first, then subs oldest-first.
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.created_at,
m.role, m.content, m.created_at
FROM chat_sessions s
LEFT JOIN chat_messages m ON m.rowid = (
SELECT rowid FROM chat_messages
WHERE session_id = s.id
ORDER BY created_at DESC, rowid DESC
LIMIT 1
)
ORDER BY (s.type = ?) DESC, s.created_at ASC`
// ListSessions returns the main session first, then every sub session, oldest first,
// each carrying a LastMessage preview of its newest message (nil when the session has
// none). Sessions and previews are read in a single query, so the query count does not
// grow with the number of sessions. The returned slice is non-nil.
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain)
if err != nil {
return nil, fmt.Errorf("history: list sessions: %w", err)
}
defer func() { _ = rows.Close() }()
sessions := make([]SessionMeta, 0)
for rows.Next() {
meta, err := scanSessionMeta(rows)
if err != nil {
return nil, fmt.Errorf("history: scan session: %w", err)
}
sessions = append(sessions, meta)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate sessions: %w", err)
}
return sessions, nil
}
// scanSessionMeta reads one joined chat_sessions + last-message row. The three message
// columns are NULL for a session that has never received a message, so they scan into
// nullable holders and only produce a LastMessage when a row is present.
func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
var (
meta SessionMeta
typeStr string
role sql.NullString
content sql.NullString
lastAt sql.NullInt64
)
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &meta.CreatedAt, &role, &content, &lastAt); err != nil {
return SessionMeta{}, err
}
meta.Type = typeStr
if role.Valid && lastAt.Valid {
meta.LastMessage = &MessageSummary{
Role: role.String,
Content: previewContent(content.String),
CreatedAt: lastAt.Int64,
}
}
return meta, nil
}
// previewContent collapses whitespace runs (newlines included) to single spaces and
// truncates to lastMessagePreviewRunes runes with an ellipsis. It is display-only.
func previewContent(content string) string {
joined := strings.Join(strings.Fields(content), " ")
runes := []rune(joined)
if len(runes) <= lastMessagePreviewRunes {
return joined
}
return string(runes[:lastMessagePreviewRunes]) + "…"
}