// 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:")。主会话与子会话共享同一角色,因而共享同一记忆;本包 // 只建模会话与消息。 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 // ClientMsgID 是客户端为这条消息生成的幂等键(Android 协同 §G4)—— 通常是 // 一条乐观气泡的本地 id,也正因此它必须原样回填并可见:客户端用它把本地 // 气泡与服务端真相对齐,而不是靠「文本相等」这类脆弱匹配。同一会话内同一 // 非空键只允许一条消息(部分唯一索引裁决,见 client_msg_id.go)。空串意味着 // 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前 // 逐字相同。 ClientMsgID 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 迁移一致(列清单随后续迁移同步增长,见 // client_msg_id.go);它是幂等的,因此存储自足、可从任意 *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") // 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后 // ensureClientMsgIDColumn 把早于该列建出的既有表前滚(幂等),最后才建索引 // —— 部分唯一索引引用 client_msg_id,列还不存在时建它必然失败。 for _, ddl := range []string{ createSessionsTable, createMessagesTable, } { if _, err := db.ExecContext(ctx, ddl); err != nil { return nil, fmt.Errorf("history: apply schema: %w", err) } } if err := ensureClientMsgIDColumn(ctx, db); err != nil { return nil, err } for _, ddl := range []string{ createMessagesIndex, createClientMsgIDIndex, } { 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 与现在的毫秒时间戳,然后插入该行并返回得到的消息。 // // msg.ClientMsgID 非空时(Android 协同 §G4,客户端幂等键)写入走 // ON CONFLICT DO NOTHING,由 (session_id, client_msg_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) } query := `INSERT INTO chat_messages (id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` if msg.ClientMsgID != "" { query += ` ON CONFLICT DO NOTHING` } res, err := s.db.ExecContext(ctx, query, 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.ClientMsgID, msg.CreatedAt) if err != nil { return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err) } if msg.ClientMsgID != "" { n, err := res.RowsAffected() if err != nil { return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err) } if n == 0 { return s.FindByClientMsgID(ctx, msg.SessionID, msg.ClientMsgID) } } 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 ` + messageColumns + ` 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 } // 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 } // messageColumns 是 scanMessage 期望的 chat_messages 列清单,也是每条读取 // 语句(List/ListAfter/Recent/Search/messageByID/FindByClientMsgID)共用的 // **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。 // 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在 // 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。 const messageColumns = `id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at` // scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。 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.ClientMsgID, &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 '', client_msg_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)`