package history import ( "context" "database/sql" "fmt" "strings" ) // 本文件负责会话列表中携带的两个派生展示字段(GET /api/v1/sessions):最后 // 消息预览与未读数。两者都是只读投影,使客户端无需逐会话请求就能渲染对话行 // (及其未读角标)。预览会被截断以便展示,两个字段都绝不参与身份或权限判定 // —— 身份来自渠道 actor 元数据 / JWT(项目红线)。 // lastMessagePreviewRunes 限制预览内容,使一条长消息不会撑大会话列表响应。 // 更长的消息会先折叠空白,再截断到这么多 rune 并加省略号。 const lastMessagePreviewRunes = 60 // MessageSummary 是会话最新消息的纯展示预览。 type MessageSummary struct { Role string // "user" | "assistant"(用户 | 助手) Content string // 已折叠空白、已截断的预览 CreatedAt int64 // Unix 毫秒 } // listSessionsQuery 用一条语句读取每个会话及其最新消息与未读数。LEFT JOIN 的 // 关联子查询经由 (session_id, created_at DESC) 索引解析每个会话最新的那一行 // (rowid DESC 按插入顺序打破同一毫秒的并列),因此没有消息的会话仍会以预览 // 列全为 NULL 的形式出现。未读数是同一个索引上的关联 COUNT(*) // (session_id = s.id AND created_at > s.read_at 是一次等值 + 范围扫描), // 因此无需逐会话往返(N+1),也不需要额外索引。排序把置顶会话浮到最前, // 再把主会话排在未置顶子会话之前,然后子会话最旧优先。 const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, s.persona_id, 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 按置顶会话在前、其后是主会话、最后是每个子会话(最旧优先)的 // 顺序返回,每个会话携带其最新消息的 LastMessage 预览(没有消息时为 nil)与 // 由会话读游标推导出的 UnreadCount。会话、预览与未读数在单次查询中读出, // 因此查询数量不随会话数增长。返回切片非 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 读取一行已连接的 chat_sessions + 最新消息 + 未读数。从未收到 // 消息的会话那三个消息列为 NULL,因此它们扫描到可空的容器里,只有该行存在时 // 才产出 LastMessage。未读数是非 NULL 的 COUNT(*),因此扫描到普通 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, &meta.PersonaID, &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 把连续空白(含换行)折叠为单个空格,并截断到 // lastMessagePreviewRunes 个 rune 加省略号。它只用于展示。 func previewContent(content string) string { joined := strings.Join(strings.Fields(content), " ") runes := []rune(joined) if len(runes) <= lastMessagePreviewRunes { return joined } return string(runes[:lastMessagePreviewRunes]) + "…" }