Files
Cyrene_v2/internal/runtime/history/store.go
T
AskaEth 74b12d87ca
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(history,loop,gateway): 上下文续接的最新性/一致性三条(裁决 13.4.5 的面内部分)
本组只落地「在允许文件面内、且能用 design/15 既有规格推出」的部分;13.4.5 的「真并行 + 动态旧上下文」
与 design/15 §2.1/§2.2 的既有串行续接规格存在读法冲突,按规程**停下上报**(未自创语义)。

- history.Recent(续接窗口)并列打破:created_at DESC, rowid DESC —— Windows time.Now() 步长 ~0.44ms,
  用户连发必然同毫秒并列;修复前并列时取**更早**的 ⇒ 丢掉刚发出的最新 n 条且同毫秒内反插入序
- 上行 chat 不再经**可丢弃**的 bus:直达会话编排器的非阻塞 Submit(design/15 §2.1「会话消息不能被 drop」
  + §4「不阻塞是硬约束」);orchestrator 为 nil 时回退 bus,旧断言仍绿
- 被**取消**(中断)的回合把该会话 checkpoint 落定为不可恢复(用 context.WithoutCancel 写,快照保留可查询,
  仅在回合未落定时触发)⇒ 不再顶掉下一条消息的上下文
- 复核:build/vet 绿 + loop/gateway/history 三包测试与 -race 全绿;新增 5 条用例(含一条真 WS+真编排器+
  真 loop+真 persist 的集成用例);**3 条变异反证**精确复现「新消息被丢弃只见旧窗口」「窗口取早不取新」
  「bus 饱和时会话消息静默消失」,均已还原
- 待裁决(已上报主人):① 「同一会话多回合真并行」需改 checkpoint 键(sessionID:step 并发互相覆盖)、
  flow 图按轮键控、前端单槽 busy —— 均在本组文件面外;② 「动态吸收新到消息」在 design/15 **无规格**,
  属新语义,须先写设计;③ 非取消的错误退出同样留可恢复快照(同机制,未扩范围,因 resume_test 正钉住旧行为)
2026-10-04 13:56:35 +08:00

601 lines
27 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 实现 DevDocs/design/16-会话持久化与主次级会话.md 描述的持久化
// 聊天历史会话模型。
//
// 它是存储层,而不是编排/上下文层:它持久化原始消息流(chat_messages)与持久化
// 会话记录(chat_sessions),使 Web 前端能请求并展示历史,子会话也能被创建、列出
// 与删除。它刻意不碰记忆(design/16 §3.2:记忆是角色作用域的,住在
// runtime/memory)、不构建上下文(runtime/loop),也不调度回合
// (gateway/session_orchestrator)。
//
// 会话模型(design/16 §2.1):有且只有一个固定主会话(id "web")与任意数量的子
// 会话(id "web:<uuid>")。主会话与子会话共享同一角色,因而共享同一记忆;本包
// 只建模会话与消息。
package history
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 会话角色与类型。这些就是持久化在 role / type 列中的取值。
const (
// RoleUser、RoleAssistant、RoleSystem 归类一条消息的作者。
RoleUser = "user"
RoleAssistant = "assistant"
RoleSystem = "system"
// SessionMain 与 SessionSub 归类一个持久化会话。
SessionMain = "main"
SessionSub = "sub"
// PlatformWeb 是源自 Web 主会话的消息所用的来源平台标签
// (design/16 §4.4)。它既是该列的默认值,也是调用方未设置
// Message.Platform 时赋的值。
PlatformWeb = "web"
)
// MainSessionID 是那个唯一主会话的固定 id(design/16 §2.1)。子会话 id 为
// MainSessionID + ":" + uuid。
const MainSessionID = "web"
// defaultPageLimit 是调用方未传限制时使用的页大小。
const defaultPageLimit = 50
// maxPageLimit 为客户端传入的 limit 设上界,使单个请求无法耗尽内存。
const maxPageLimit = 200
// 区分 history 包各类失败的哨兵错误。调用方用 %w 包装它们,使 errors.Is 能穿透
// 错误链。
var (
// ErrNilDB 在 NewStore 收到 nil 的 *sql.DB 时返回。
ErrNilDB = errors.New("history: nil db")
// ErrImmutableMain 在调用方试图删除固定主会话时返回。
ErrImmutableMain = errors.New("history: the main session cannot be deleted")
// ErrMessageNotFound 在没有 chat_messages 行携带所请求 id 时由
// UpdateMessage/DeleteMessage 返回。
ErrMessageNotFound = errors.New("history: message not found")
)
// Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任
// 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条
// 消息的 created_at。
type Message struct {
ID string
SessionID string
Role string
Content string
Mode string
Mood string
Image string
Thinking string
// Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台
// 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台
// (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认
// 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。
Platform string
// DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出
// (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的
// "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个
// 客户端共享 Platform,但 DeviceID 不同。
DeviceID string
// SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方
// 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者
// 是谁;对主会话上主人的私聊它刻意留空(主人从不被标注)。它只是展示
// 元数据 —— 身份绝不由该字段判定。
SenderName string
// AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如
// 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸
// 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空,
// 因此对 Web 消息它恒为空。
AdapterID string
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留
// 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨
// 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色
// 之前的行、以及任何未显式指定角色写入的消息,因此它们读起来与以往完全
// 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定
// (项目红线)。
PersonaID string
CreatedAt int64
}
// SessionMeta 是一个持久化会话面向线上的元数据。
type SessionMeta struct {
ID string
Type string // "main" | "sub"(主会话 | 子会话)
Title string
CreatedAt int64
// Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限
// 绝不由它推导。
Pinned bool
// LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中
// 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求;
// 身份与权限绝不由它推导。
LastMessage *MessageSummary
// UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由
// ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端
// 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是
// 展示用 —— 身份与权限绝不由它推导。
UnreadCount int
// PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由
// 哪个角色应答」的唯一事实来源:循环据此解析记忆作用域、系统提示词、
// 情绪与思考预算。为空意味着「继承当前全局默认角色」
// (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定
// 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随
// 默认角色变更(design/22 §7-6)。
PersonaID string
}
// HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有
// 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际
// 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里
// (internal/services/gateway 的 contracts.go),这样别处更窄的存储替身不必
// 实现一个它从不推进的游标。*Store 同时满足两者。
type HistoryStore interface {
// Append 持久化 msg,在调用方省略时分配 id 与时间戳。
Append(ctx context.Context, msg Message) error
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息(含分配的
// id/时间戳),使调用方能广播它做跨端同步。
AppendFull(ctx context.Context, msg Message) (Message, error)
// List 按最新优先返回 sessionID 的至多 limit 条消息。before 是毫秒时间戳
// 游标:非零时只返回早于 before 的消息(不含边界,与前端 "before" 语义
// 一致)。platform 是可选的来源平台过滤(design/16 §4.4):非空时只返回
// 携带该平台的消息;为空时返回全部平台(Web 客户端请求完整主会话,IM
// 适配器只请求自己的平台)。
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error)
// ListAfter 按最旧优先返回 sessionID 中严格新于 after(created_at >
// after)的至多 limit 条消息,使重连的客户端能从自己已见过的最新
// createdAt 向前拉取错过的消息。platform 的过滤同 List。limit 钳制到
// [1, maxPageLimit]。
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error)
// Recent 按最旧优先返回 sessionID 最近的 n 条消息,供 WS 回放 / 上下文
// 尾部使用。
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
// ListPlatformLog 按最新优先返回某个 IM 平台跨所有非主(隔离)会话的至多
// limit 条消息,供适配器日志页使用(design/16 §4.6 §4)。它排除固定主 Web
// 会话,从而在不带主人私聊镜像的情况下浮现该平台的非主人消息流
// (群聊 + 陌生人私聊)。before 是毫秒游标;0 表示最新一页。
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
// ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。与
// ListPlatformLog 不同,它不排除主 Web 会话:主人的 IM 私聊住在主会话里
// 但仍携带 adapter_id,因此按适配器的日志包含主人镜像。before 是毫秒
// 游标;0 表示最新一页。adapterID 为空时返回空切片。
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error)
// ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多
// limit 条消息,供适配器总览页使用。before 是毫秒游标;0 表示最新一页。
ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error)
// DeleteSession 删除一个子会话及其全部消息。主会话不可变,返回
// ErrImmutableMain。
DeleteSession(ctx context.Context, sessionID string) error
// Search 按最新优先返回 sessionID 中内容包含 query 的至多 limit 条消息
// (ASCII 大小写不敏感的 LIKE,%/_/\ 已转义)。before 与 List 是同一个
// 毫秒游标;limit 钳制到 [1, maxPageLimit]。空 query 返回空切片(调用方
// 退回 List 做普通翻页)。Search 始终限定在单个会话内,因此隔离的
// 群聊/陌生人会话绝不浮现在另一个会话的结果中。
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error)
// UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull
// 一样,使调用方能无需二次读取就广播它)。只有 content 变化;role、时间
// 戳、platform 保持已存值。未知 id 返回 ErrMessageNotFound。
UpdateMessage(ctx context.Context, id, content string) (Message, error)
// DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。
DeleteMessage(ctx context.Context, id string) error
// RenameSession 设置会话的显示标题。主会话与子会话都可重命名(标题只是
// 展示元数据);未知 id 返回 ErrSessionNotFound。
RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned 置顶或取消置顶一个会话。置顶只是展示/排序元数据;主会话与
// 子会话都可置顶。未知 id 返回 ErrSessionNotFound。
SetPinned(ctx context.Context, sessionID string, pinned bool) error
// NewSubSession 创建一个子会话并返回其 id(MainSessionID:uuid)。它不绑定
// 显式角色,因此新会话继承全局默认(persona_id 为空);*Store 另提供
// NewSubSessionFor 这个绑定角色的变体(design/22 §4.1),本窄接口刻意
// 省略它,因为 channelbridge 与 backthink 都不创建会话。
NewSubSession(ctx context.Context, ownerID string) (string, error)
// ListSessions 先返回置顶会话,再返回主会话,最后返回每个子会话(最旧
// 优先)。
ListSessions(ctx context.Context) ([]SessionMeta, error)
}
// Store 是基于 SQLite 的聊天历史存储。它拥有 chat_sessions 与 chat_messages
// 两张表。并发交给 *sql.DB(可安全并发使用),因此 Store 不再持有额外的锁。
// 每条查询都参数化,读取时显式列出列(不用 SELECT *);读取都接受 context。
type Store struct {
db *sql.DB
log *slog.Logger
}
// rowScanner 由 *sql.Row 与 *sql.Rows 同时满足,因此单个 scanMessage 辅助函数
// 就能服务 List 与 Recent。
type rowScanner interface {
Scan(dest ...any) error
}
// NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在
// 且固定主会话行在位。该 DDL 与 0003 迁移一致;它是幂等的,因此存储自足、可从
// 任意 *sql.DB 使用,与 memory 和 session 包一致。db 必须可用;nil 返回
// ErrNilDB。log 可以为 nil。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(discardLogger(log), "history")
for _, ddl := range []string{
createSessionsTable,
createMessagesTable,
createMessagesIndex,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("history: apply schema: %w", err)
}
}
s := &Store{db: db, log: log}
if err := s.ensureMainSession(ctx); err != nil {
return nil, err
}
return s, nil
}
// Append 持久化 msg,在调用方把 id/CreatedAt 留空/留零时分配新的 id 与现在的
// 毫秒时间戳。它返回包装了操作名的错误。需要拿回生成的 id/时间戳的调用方应
// 改用 AppendFull。
func (s *Store) Append(ctx context.Context, msg Message) error {
_, err := s.insert(ctx, msg)
return err
}
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息 —— 即调用方留空/
// 留零时存储分配的 id 与 CreatedAt —— 使调用方能广播它做跨端实时同步
// (design/16 §4.2),无需二次读取。
func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) {
return s.insert(ctx, msg)
}
// insert 是 Append/AppendFull 共用的主体。它在调用方把 id/CreatedAt 留空/留零时
// 分配新的 id 与现在的毫秒时间戳,然后插入该行并返回得到的消息。
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.ID == "" {
id, err := newID()
if err != nil {
return Message{}, fmt.Errorf("history: append id: %w", err)
}
msg.ID = id
}
if msg.CreatedAt == 0 {
msg.CreatedAt = time.Now().UnixMilli()
}
if msg.Platform == "" {
msg.Platform = PlatformWeb
}
if msg.Role == RoleUser {
s.ensureTitle(ctx, msg.SessionID, msg.Content)
}
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.CreatedAt)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
return msg, nil
}
// List 按最新优先返回 sessionID 的至多 limit 条消息。before 非零时把查询限制为
// 严格早于 before 的消息,使客户端可以传入上一页最旧那条消息的 created_at 向上
// 翻页。platform 非空时把该页进一步限制为携带该来源平台的消息(design/16 §4.4:
// Web 客户端省略 platform 以拿到完整主会话,IM 适配器传入自己的平台只看自己的
// 通道)。返回切片非 nil。limit 钳制到 [1, maxPageLimit]。
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE session_id = ?`
args := []any{sessionID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
if platform != "" {
query += ` AND platform = ?`
args = append(args, platform)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: list %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan list %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate list %s: %w", sessionID, err)
}
return msgs, nil
}
// ListPlatformLog 按最新优先返回 platform 跨所有非主(隔离)会话的至多 limit
// 条消息。主会话消息被排除,因此只有该平台自己的消息流(群聊 + 陌生人私聊)
// 浮现到适配器日志页(design/16 §4.6 §4)。before 是毫秒游标;非零时只返回
// 严格更旧的消息。返回切片非 nil。
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE platform = ? AND session_id != ?`
args := []any{platform, MainSessionID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: platform log %s: %w", platform, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan platform log %s: %w", platform, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate platform log %s: %w", platform, err)
}
return msgs, nil
}
// ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。它不排除主 Web
// 会话:主人的 IM 私聊住在主会话里但仍携带 adapter_id,因此按适配器的日志包含
// 主人镜像。adapterID 为空时返回空(非 nil)切片。before 是毫秒游标;非零时只
// 返回严格更旧的消息。返回切片非 nil。
func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
if adapterID == "" {
return make([]Message, 0), nil
}
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE adapter_id = ?`
args := []any{adapterID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: adapter log %s: %w", adapterID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan adapter log %s: %w", adapterID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate adapter log %s: %w", adapterID, err)
}
return msgs, nil
}
// ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多 limit
// 条消息,供适配器总览页使用。before 是毫秒游标;非零时只返回严格更旧的消息。
// 返回切片非 nil。
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE adapter_id != ''`
var args []any
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: all adapter log: %w", err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan all adapter log: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate all adapter log: %w", err)
}
return msgs, nil
}
// Recent 按最旧优先返回 sessionID 最近的 n 条消息(最靠尾部的那段窗口),供
// WS 回放 / 上下文尾部使用。返回切片非 nil。
//
// 并列打破(裁决 13.4.5 的上下文「最新性/一致性」):同一毫秒写入的多条消息
// 在 created_at 上并列,而 Windows 上 time.Now() 的步长本就约 0.44ms —— 用户
// 连发多条(design/15 §2.1 的忙碌续接场景)时并列是常态。`rowid DESC` 让 LIMIT
// 取到的是**最后插入**的那几条(否则窗口会留下更旧的、丢掉刚发出的),而反转后
// 同毫秒内即回到插入顺序,使续接窗口的时序与真实对话一致。该口径与 ListAfter
// 的「rowid 打破 created_at 的并列」相同。
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
if n < 1 {
n = defaultPageLimit
}
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?`, sessionID, n)
if err != nil {
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
// Recent 让最新的排在最后,使窗口处于时间顺序;先按查询顺序构建再原地反转。
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan recent %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate recent %s: %w", sessionID, err)
}
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
// 返回 ErrImmutableMain(design/16 §6)。
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
if sessionID == MainSessionID {
return ErrImmutableMain
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("history: begin delete %s: %w", sessionID, err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_messages WHERE session_id = ?`, sessionID); err != nil {
return fmt.Errorf("history: delete messages %s: %w", sessionID, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_sessions WHERE id = ? AND type = ?`, sessionID, SessionSub); err != nil {
return fmt.Errorf("history: delete session %s: %w", sessionID, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("history: commit delete %s: %w", sessionID, err)
}
return nil
}
// NewSubSession 创建一个不绑定显式角色的子会话并返回其 id
// (MainSessionID:uuid)。新会话因此继承全局默认角色(persona_id 为空);要在
// 创建时绑定角色请用 NewSubSessionFor。ownerID 为接口完整性而接受(Web 主人
// 由 JWT 识别,不按会话存储);它只以 debug 级别记日志,不被持久化。
func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) {
return s.NewSubSessionFor(ctx, ownerID, "")
}
// ensureMainSession 在固定主会话行缺失时插入它,使 ListSessions 在任何消息写入
// 之前也能报告出主会话。
func (s *Store) ensureMainSession(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO chat_sessions (id, type, title, created_at)
VALUES (?, ?, ?, ?)`, MainSessionID, SessionMain, "", time.Now().UnixMilli())
if err != nil {
return fmt.Errorf("history: ensure main session: %w", err)
}
return nil
}
// scanMessage 读取一行 chat_messages。消息各列按位置扫描。
func scanMessage(sc rowScanner) (Message, error) {
var m Message
var mode, mood, platform, deviceID, senderName, adapterID string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.CreatedAt); err != nil {
return Message{}, err
}
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
return m, nil
}
// clampLimit 把 limit 钳制到 [1, maxPageLimit];调用方传入的 0 或负数回退到
// defaultPageLimit。
func clampLimit(limit int) int {
if limit < 1 {
return defaultPageLimit
}
if limit > maxPageLimit {
return maxPageLimit
}
return limit
}
// newID 返回一个 128 位密码学随机的 id,以十六进制编码。它避免引入 UUID 依赖,
// 使本包保持自足。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("history: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个什么都不写的日志器。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// Schema DDL。internal/db 里的 0003 迁移是领域表的权威机制,但这里刻意再幂等
// 地发出一次,使存储自足、可从任意 *sql.DB 使用(与 memory 和 session 包一致)。
const createSessionsTable = `
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
read_at INTEGER NOT NULL DEFAULT 0,
persona_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
const createMessagesTable = `
CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
mode TEXT,
mood TEXT,
image TEXT,
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
persona_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`
const createMessagesIndex = `
CREATE INDEX IF NOT EXISTS idx_chat_msgs_session_created
ON chat_messages (session_id, created_at DESC)`