- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 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)
108 lines
4.3 KiB
Go
108 lines
4.3 KiB
Go
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]) + "…"
|
||
}
|