迁移 0018:ALTER TABLE chat_messages ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''
(与 persona_id / client_msg_id / document 同族:不用 NULL,空串 = 不属于任何已知回合 ⇒ 旧行零迁移、键省略)。
存储层全链路带 turn_id(列清单唯一一份 ⇒ List/ListAfter/Recent/Search/FindByClientMsgID 自动同源);
新增 history/turn_id.go:ensureTurnIDColumn(既有库幂等前滚)+ SetMessagesTurnID
(第二段补写:单事务参数化、先到先得 WHERE turn_id = ''、幂等、未知 id 忽略)。
DTO:historyMessage.TurnID(json turnId,omitempty)⇒ REST 列表 / admin 渠道日志 / WS 握手回放 /
message_persisted / channel_persisted / message_edited 一次全覆盖。
写入面:persistUserMessages 读 ctx 回合标识;persistAssistantReply 增可选尾参(既有调用点零改动);
withTurnID / turnIDFrom / assignTurnID / turnIDOfLeg。
⚠️ 编排器把 turnId 交给落库的 4 处(新起一路 / 续跑路 / 被 steer 吃进 / 助手回复)尚未接线
(那些文件当时被另一路占用);当前生产行为 turn_id 仍为 ''。清单已在切片报告里精确到文件:行。
13 新用例 + 5 条变异(INSERT 不带列 / DTO 不回填 / 去掉 omitempty / 去掉先到先得 / 不回滚前滚)。
为守 session_persist.go ≤500 行,turnId 一节拆到新文件 session_turn_id.go。
430 lines
20 KiB
Go
430 lines
20 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")
|
||
)
|
||
|
||
// 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
|
||
// refs 是文档引用计数的写入面(切片 S7,见 refs.go)。未注入时为 nil,
|
||
// 此时消息读写照旧、只是不维护 documents.ref_count。
|
||
refs DocumentRefCounter
|
||
}
|
||
|
||
// 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 迁移一致(列清单随后续迁移同步增长,前滚
|
||
// 见 document.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
|
||
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。
|
||
//
|
||
// opts 是可选的接线面(见 refs.go):组合根用 WithDocumentRefs 注入引用计数写入
|
||
// 面;不传时 Store 的行为与切片 S7 之前逐字相同。
|
||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger, opts ...StoreOption) (*Store, error) {
|
||
if db == nil {
|
||
return nil, ErrNilDB
|
||
}
|
||
log = logger.WithSource(discardLogger(log), "history")
|
||
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
|
||
// ensureMessageColumns 把早于这些列(client_msg_id / document / turn_id)建出的
|
||
// 既有表前滚(幂等),最后才建索引 —— 部分唯一索引引用 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 := ensureMessageColumns(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}
|
||
for _, opt := range opts {
|
||
opt(s)
|
||
}
|
||
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) 的部分唯一索引**原子**
|
||
// 裁决谁写下了这一行;命中冲突时不写入,改读回既有那一行返回(因此调用方拿到
|
||
// 的是权威消息,而不是一条并不存在的「刚插入的行」)。判重不在这里「先查再插」
|
||
// —— 那是两个并发重发都能通过检查、然后双双插入的竞态。
|
||
//
|
||
// 该行携带文档引用时(msg.Document 非空),引用计数的 +1 与这一行**同一个事务**
|
||
// 落定(切片 S7):命中幂等冲突时不计数(那一行不是本次写的),计数写失败则整条
|
||
// 消息回滚 —— 绝不留下「消息在库里、计数没加」这种让 GC 误删仍被引用文件的窗口。
|
||
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, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
if msg.ClientMsgID != "" {
|
||
query += ` ON CONFLICT DO NOTHING`
|
||
}
|
||
tx, err := s.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
res, err := tx.ExecContext(ctx, query,
|
||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image,
|
||
msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID,
|
||
msg.PersonaID, msg.ClientMsgID, msg.TurnID, 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 {
|
||
// 冲突:本次没有写下任何行,因此也没有引用要记。先释放事务再读回
|
||
// 权威行(连接池只有一条连接,读回必须等事务让出它)。
|
||
_ = tx.Rollback()
|
||
return s.FindByClientMsgID(ctx, msg.SessionID, msg.ClientMsgID)
|
||
}
|
||
}
|
||
if err := s.retainDocuments(ctx, tx, msg.Document); err != nil {
|
||
return Message{}, err
|
||
}
|
||
if err := tx.Commit(); 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 ` + 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
|
||
}
|
||
|
||
// 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, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_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.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.TurnID, &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,
|
||
document TEXT NOT NULL DEFAULT '',
|
||
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 '',
|
||
turn_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)`
|