Files
Cyrene_v2/internal/runtime/history/session_preview.go
T
AskaEth 09fba77f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/history(15 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 4 条行尾注释的翻译,代码前缀全等)
- 交叉引用(design/16 §4.6、D4/B3、0010 迁移文件名)逐字保留
- 保留未译技术词:DDL/rowid/LIKE/LEFT JOIN/DTO/rune/WS/actor 及全部标识符与表名
- 顺带副作用:store.go、session_persona_test.go 由注释文字改变而变得 gofmt 干净(未运行 gofmt -w)
2026-10-04 12:28:48 +08:00

108 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]) + "…"
}