- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
`clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
(0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
499 lines
24 KiB
Go
499 lines
24 KiB
Go
// 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
|
||
// 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)`
|