Files
Cyrene_v2/internal/runtime/history/session_preview.go
T
AskaEth e8eccd2bb2 feat(history,gateway): D4 会话未读/已读游标 — unreadCount + POST /sessions/{id}/read + session_read 广播
- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
2026-10-03 16:47:55 +08:00

115 lines
4.5 KiB
Go

package history
import (
"context"
"database/sql"
"fmt"
"strings"
)
// This file owns the two derived per-session display fields carried in the session
// list (GET /api/v1/sessions): the last-message preview and the unread count. Both are
// read-only projections so a client can render a conversation row (with its unread
// badge) without one request per session. The preview is truncated for display, and
// neither field ever 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 and its unread count
// 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. The unread count is a correlated COUNT(*) over the same index
// (session_id = s.id AND created_at > s.read_at is an equality + range scan), so no
// per-session round trip (N+1) is needed and no extra index is required. Ordering floats
// pinned sessions first, then keeps the main session before unpinned subs, then subs
// oldest-first.
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
m.role, m.content, m.created_at,
(SELECT COUNT(*) FROM chat_messages c
WHERE c.session_id = s.id AND c.created_at > s.read_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.pinned DESC, (s.type = ?) DESC, s.created_at ASC`
// ListSessions returns pinned sessions first, then the main session, then every sub
// session, oldest first, each carrying a LastMessage preview of its newest message (nil
// when the session has none) and the UnreadCount derived from the session's read cursor.
// Sessions, previews and unread counts 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 + unread-count 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. The
// unread count is a non-NULL COUNT(*), so it scans into a plain int.
func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
var (
meta SessionMeta
typeStr string
pinned int
role sql.NullString
content sql.NullString
lastAt sql.NullInt64
)
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
return SessionMeta{}, err
}
meta.Type = typeStr
meta.Pinned = pinned != 0
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]) + "…"
}