docs(comments): 注释中文化 · internal/runtime/history(15 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 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)
This commit is contained in:
1 parent
b0fbb8bc0d
commit
09fba77f87
15 files changed
+430
-516
No files matched your search
+172
-206
@@ -1,16 +1,15 @@
|
||||
// Package history implements the persistent chat-history session model described in
|
||||
// DevDocs/design/16-会话持久化与主次级会话.md.
|
||||
// Package history 实现 DevDocs/design/16-会话持久化与主次级会话.md 描述的持久化
|
||||
// 聊天历史会话模型。
|
||||
//
|
||||
// It is the storage layer, not the orchestration/context layer: it persists the raw
|
||||
// message stream (chat_messages) and the persistent session records (chat_sessions)
|
||||
// so the Web frontend can request + display history, and secondary (sub) sessions can
|
||||
// be created, listed and deleted. It intentionally does not touch memory (design/16
|
||||
// §3.2: memory is persona-scoped and lives in runtime/memory), does not build context
|
||||
// (runtime/loop) and does not schedule turns (gateway/session_orchestrator).
|
||||
// 它是存储层,而不是编排/上下文层:它持久化原始消息流(chat_messages)与持久化
|
||||
// 会话记录(chat_sessions),使 Web 前端能请求并展示历史,子会话也能被创建、列出
|
||||
// 与删除。它刻意不碰记忆(design/16 §3.2:记忆是角色作用域的,住在
|
||||
// runtime/memory)、不构建上下文(runtime/loop),也不调度回合
|
||||
// (gateway/session_orchestrator)。
|
||||
//
|
||||
// Session model (design/16 §2.1): there is exactly one fixed main session (id "web") and
|
||||
// any number of sub sessions (id "web:<uuid>"). Main + sub share the same persona and
|
||||
// therefore the same memory; this package only models sessions + messages.
|
||||
// 会话模型(design/16 §2.1):有且只有一个固定主会话(id "web")与任意数量的子
|
||||
// 会话(id "web:<uuid>")。主会话与子会话共享同一角色,因而共享同一记忆;本包
|
||||
// 只建模会话与消息。
|
||||
package history
|
||||
|
||||
import (
|
||||
@@ -27,50 +26,50 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Session roles and types. These are the values persisted in the role / type columns.
|
||||
// 会话角色与类型。这些就是持久化在 role / type 列中的取值。
|
||||
const (
|
||||
// RoleUser, RoleAssistant, RoleSystem classify a message's author.
|
||||
// RoleUser、RoleAssistant、RoleSystem 归类一条消息的作者。
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleSystem = "system"
|
||||
|
||||
// SessionMain and SessionSub classify a persistent session.
|
||||
// SessionMain 与 SessionSub 归类一个持久化会话。
|
||||
SessionMain = "main"
|
||||
SessionSub = "sub"
|
||||
|
||||
// PlatformWeb is the source-platform tag for messages that originate on the Web
|
||||
// main session (design/16 §4.4). It is both the column default and the value
|
||||
// assigned when a caller leaves Message.Platform empty.
|
||||
// PlatformWeb 是源自 Web 主会话的消息所用的来源平台标签
|
||||
// (design/16 §4.4)。它既是该列的默认值,也是调用方未设置
|
||||
// Message.Platform 时赋的值。
|
||||
PlatformWeb = "web"
|
||||
)
|
||||
|
||||
// MainSessionID is the fixed id of the one main session (design/16 §2.1). Sub session
|
||||
// ids are MainSessionID + ":" + uuid.
|
||||
// MainSessionID 是那个唯一主会话的固定 id(design/16 §2.1)。子会话 id 为
|
||||
// MainSessionID + ":" + uuid。
|
||||
const MainSessionID = "web"
|
||||
|
||||
// defaultPageLimit is the page size used when the caller does not pass one.
|
||||
// defaultPageLimit 是调用方未传限制时使用的页大小。
|
||||
const defaultPageLimit = 50
|
||||
|
||||
// maxPageLimit bounds a client-supplied limit so a single request cannot drain memory.
|
||||
// maxPageLimit 为客户端传入的 limit 设上界,使单个请求无法耗尽内存。
|
||||
const maxPageLimit = 200
|
||||
|
||||
// Sentinels distinguishing history package failures. Callers wrap them with %w so
|
||||
// errors.Is works through the chain.
|
||||
// 区分 history 包各类失败的哨兵错误。调用方用 %w 包装它们,使 errors.Is 能穿透
|
||||
// 错误链。
|
||||
var (
|
||||
// ErrNilDB is returned when NewStore receives a nil *sql.DB.
|
||||
// ErrNilDB 在 NewStore 收到 nil 的 *sql.DB 时返回。
|
||||
ErrNilDB = errors.New("history: nil db")
|
||||
|
||||
// ErrImmutableMain is returned when a caller tries to delete the fixed main session.
|
||||
// ErrImmutableMain 在调用方试图删除固定主会话时返回。
|
||||
ErrImmutableMain = errors.New("history: the main session cannot be deleted")
|
||||
|
||||
// ErrMessageNotFound is returned by UpdateMessage/DeleteMessage when no
|
||||
// chat_messages row carries the requested id.
|
||||
// ErrMessageNotFound 在没有 chat_messages 行携带所请求 id 时由
|
||||
// UpdateMessage/DeleteMessage 返回。
|
||||
ErrMessageNotFound = errors.New("history: message not found")
|
||||
)
|
||||
|
||||
// Message is one persisted chat message. CreatedAt is a Unix millisecond timestamp and
|
||||
// doubles as the pagination cursor (design/16 §3.1): pages are ordered newest-first and
|
||||
// "before" is the created_at of the oldest message on the previous page.
|
||||
// Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任
|
||||
// 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条
|
||||
// 消息的 created_at。
|
||||
type Message struct {
|
||||
ID string
|
||||
SessionID string
|
||||
@@ -80,174 +79,151 @@ type Message struct {
|
||||
Mood string
|
||||
Image string
|
||||
Thinking string
|
||||
// Platform tags the source platform of a message (design/16 §4.4). The Web main
|
||||
// session is shared across platforms: messages that originate on the Web carry
|
||||
// "web", while IM-owner-private messages carry the channel platform (e.g.
|
||||
// "telegram", "qq-obv11", "weixin-ilink"). An empty Platform defaults to "web" so
|
||||
// legacy/explicitly-Web writes stay on the Web lane without a separate assignment.
|
||||
// Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台
|
||||
// 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台
|
||||
// (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认
|
||||
// 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。
|
||||
Platform string
|
||||
// DeviceID is a short, client-produced identifier for the concrete device/app
|
||||
// that sent the message (design/16 §4.5) — e.g. "web:chrome:a1b2c3" for a
|
||||
// browser, "weixin-ilink:conf0:wx_uid_123" for IM. It complements Platform: two
|
||||
// clients on the same platform share Platform but differ in DeviceID.
|
||||
// DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出
|
||||
// (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的
|
||||
// "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个
|
||||
// 客户端共享 Platform,但 DeviceID 不同。
|
||||
DeviceID string
|
||||
// SenderName is the human-readable display name of the message's sender
|
||||
// (design/16 §4.6). It is set for third-party messages (group chats and
|
||||
// stranger private chats) so the agent and the IM adapter log page can label
|
||||
// who is speaking; it is intentionally blank for the owner's private chat on
|
||||
// the main session (the owner is never labeled). It is display metadata only —
|
||||
// identity is never decided from this field.
|
||||
// SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方
|
||||
// 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者
|
||||
// 是谁;对主会话上主人的私聊它刻意留空(主人从不被标注)。它只是展示
|
||||
// 元数据 —— 身份绝不由该字段判定。
|
||||
SenderName string
|
||||
// AdapterID is the adapter (platform + account) registration name of the IM
|
||||
// source that produced the message, e.g. "kook:3bef..." for a multi-account
|
||||
// channels.json registration or a bare platform name (e.g. "kook") when the
|
||||
// account comes from env. IM adapter-originated messages carry it; the Web main
|
||||
// session leaves it empty, so it is always '' for Web messages.
|
||||
// AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如
|
||||
// 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸
|
||||
// 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空,
|
||||
// 因此对 Web 消息它恒为空。
|
||||
AdapterID string
|
||||
// PersonaID is the role that SPOKE this message (design/22 §7-5). A session
|
||||
// may switch roles while keeping its context, so the authoring role is recorded
|
||||
// per message and the context/UI can tell "who said the Nth turn". Empty means
|
||||
// "inherit the current global default role" — the pre-multi-character rows and
|
||||
// any message written without an explicit role, so they read exactly as before.
|
||||
// It is display/attribution metadata only: identity and permissions are decided
|
||||
// by the channel actor metadata (project red line).
|
||||
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留
|
||||
// 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨
|
||||
// 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色
|
||||
// 之前的行、以及任何未显式指定角色写入的消息,因此它们读起来与以往完全
|
||||
// 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定
|
||||
// (项目红线)。
|
||||
PersonaID string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// SessionMeta is the wire-facing metadata of one persistent session.
|
||||
// SessionMeta 是一个持久化会话面向线上的元数据。
|
||||
type SessionMeta struct {
|
||||
ID string
|
||||
Type string // "main" | "sub"
|
||||
Type string // "main" | "sub"(主会话 | 子会话)
|
||||
Title string
|
||||
CreatedAt int64
|
||||
// Pinned floats the session to the top of ListSessions. It is display/order
|
||||
// metadata only; identity and permissions never derive from it.
|
||||
// Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限
|
||||
// 绝不由它推导。
|
||||
Pinned bool
|
||||
// LastMessage is a display-only preview of the session's newest message, filled
|
||||
// by ListSessions in the same query. It is nil for a session with no messages.
|
||||
// A client uses it to render the conversation row without a per-session request;
|
||||
// identity and permissions never derive from it.
|
||||
// LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中
|
||||
// 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求;
|
||||
// 身份与权限绝不由它推导。
|
||||
LastMessage *MessageSummary
|
||||
// UnreadCount is the number of messages newer than the session's read cursor
|
||||
// (design/16 §4.2, D4), computed by ListSessions in the same query. The cursor
|
||||
// is session-scoped and single-owner: reading on any one end marks the session
|
||||
// read for every end of the account. Like LastMessage it is display-only —
|
||||
// identity and permissions never derive from it.
|
||||
// UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由
|
||||
// ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端
|
||||
// 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是
|
||||
// 展示用 —— 身份与权限绝不由它推导。
|
||||
UnreadCount int
|
||||
// PersonaID is the role this session belongs to (design/22 §2/§4.1). It is the
|
||||
// single source of truth for "which role answers in this conversation": the loop
|
||||
// resolves memory scope, system prompt, emotion and thinking budget from it. Empty
|
||||
// means "inherit the current global default role" (owner.yaml:defaultPersona), so
|
||||
// every pre-existing session and any session created without an explicit role
|
||||
// keeps the pre-multi-character behavior (design/22 §4.7) and still follows a
|
||||
// default-role change (design/22 §7-6).
|
||||
// PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由
|
||||
// 哪个角色应答」的唯一事实来源:循环据此解析记忆作用域、系统提示词、
|
||||
// 情绪与思考预算。为空意味着「继承当前全局默认角色」
|
||||
// (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定
|
||||
// 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随
|
||||
// 默认角色变更(design/22 §7-6)。
|
||||
PersonaID string
|
||||
}
|
||||
|
||||
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
|
||||
// hold an interface rather than a concrete *Store. It is deliberately kept to the calls
|
||||
// the runtime-level consumers (channelbridge, backthink) actually make: the D4 read
|
||||
// cursor (MarkSessionRead) is declared on the consumer-side interface in
|
||||
// internal/services/gateway (contracts.go) instead, so a narrower store fake elsewhere
|
||||
// does not have to implement a cursor it never advances. *Store satisfies both.
|
||||
// HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有
|
||||
// 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际
|
||||
// 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里
|
||||
// (internal/services/gateway 的 contracts.go),这样别处更窄的存储替身不必
|
||||
// 实现一个它从不推进的游标。*Store 同时满足两者。
|
||||
type HistoryStore interface {
|
||||
// Append persists msg, assigning an id and timestamp when the caller omitted them.
|
||||
// Append 持久化 msg,在调用方省略时分配 id 与时间戳。
|
||||
Append(ctx context.Context, msg Message) error
|
||||
// AppendFull persists msg like Append but returns the complete stored message
|
||||
// (with the assigned id/timestamp) so a caller can broadcast it for cross-end sync.
|
||||
// AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息(含分配的
|
||||
// id/时间戳),使调用方能广播它做跨端同步。
|
||||
AppendFull(ctx context.Context, msg Message) (Message, error)
|
||||
// List returns up to limit messages for sessionID, newest first. before is a
|
||||
// millisecond timestamp cursor: when nonzero, only messages older than before are
|
||||
// returned (inclusive-free, matching the "before" bound on the frontend). platform
|
||||
// is an optional source-platform filter (design/16 §4.4): when non-empty only
|
||||
// messages carrying that platform are returned; when empty all platforms are
|
||||
// returned (the Web client requests the full main session, an IM adapter asks for
|
||||
// its own platform only).
|
||||
// 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 returns up to limit messages for sessionID strictly newer than after
|
||||
// (created_at > after) in oldest-first order, so a client that reconnects can pull
|
||||
// the messages it missed forward from the newest createdAt it has already seen.
|
||||
// platform filters as in List. limit is clamped to [1, maxPageLimit].
|
||||
// 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 returns the most recent n messages for sessionID in oldest-first order,
|
||||
// for WS replay / context tail usage.
|
||||
// Recent 按最旧优先返回 sessionID 最近的 n 条消息,供 WS 回放 / 上下文
|
||||
// 尾部使用。
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
|
||||
// ListPlatformLog returns up to limit messages for an IM platform across every
|
||||
// non-main (isolated) session, newest first, for the adapter log page (design/16
|
||||
// §4.6 §4). It excludes the fixed main web session, so the platform's non-owner
|
||||
// message stream (groups + stranger private chats) is surfaced without the
|
||||
// owner-private mirror. before is a millisecond cursor; 0 means newest page.
|
||||
// ListPlatformLog 按最新优先返回某个 IM 平台跨所有非主(隔离)会话的至多
|
||||
// limit 条消息,供适配器日志页使用(design/16 §4.6 §4)。它排除固定主 Web
|
||||
// 会话,从而在不带主人私聊镜像的情况下浮现该平台的非主人消息流
|
||||
// (群聊 + 陌生人私聊)。before 是毫秒游标;0 表示最新一页。
|
||||
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
|
||||
// ListAdapterLog returns up to limit messages for a single IM adapter (platform +
|
||||
// account registration name, e.g. "kook:3bef..."), newest first, for the
|
||||
// per-adapter log page. Unlike ListPlatformLog it does NOT exclude the main web
|
||||
// session: an owner-private IM chat lives in the main session but still carries
|
||||
// the adapter_id, so a per-adapter log includes the owner mirror. before is a
|
||||
// millisecond cursor; 0 means the newest page. An empty adapterID returns an
|
||||
// empty slice.
|
||||
// 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 returns up to limit messages across every IM adapter source
|
||||
// (adapter_id != ''), newest first, for the adapter overview page. before is a
|
||||
// millisecond cursor; 0 means the newest page.
|
||||
// ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多
|
||||
// limit 条消息,供适配器总览页使用。before 是毫秒游标;0 表示最新一页。
|
||||
ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error)
|
||||
// DeleteSession removes a sub session and all its messages. The main session is
|
||||
// immutable and returns ErrImmutableMain.
|
||||
// DeleteSession 删除一个子会话及其全部消息。主会话不可变,返回
|
||||
// ErrImmutableMain。
|
||||
DeleteSession(ctx context.Context, sessionID string) error
|
||||
// Search returns up to limit messages for sessionID whose content contains query
|
||||
// (case-insensitive ASCII LIKE, with %/_/\ escaped), newest first. before is the
|
||||
// same millisecond cursor as List; limit is clamped to [1, maxPageLimit]. An empty
|
||||
// query returns an empty slice (the caller falls back to List for plain paging).
|
||||
// Search is always scoped to one session, so an isolated group/stranger session can
|
||||
// never surface in another session's results.
|
||||
// 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 replaces the content of the message with id and returns the
|
||||
// updated message (like AppendFull, so the caller can broadcast it without a
|
||||
// second read). Only content changes; role/timestamps/platform stay as stored.
|
||||
// An unknown id returns ErrMessageNotFound.
|
||||
// UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull
|
||||
// 一样,使调用方能无需二次读取就广播它)。只有 content 变化;role、时间
|
||||
// 戳、platform 保持已存值。未知 id 返回 ErrMessageNotFound。
|
||||
UpdateMessage(ctx context.Context, id, content string) (Message, error)
|
||||
// DeleteMessage removes a single message. An unknown id returns
|
||||
// ErrMessageNotFound.
|
||||
// DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。
|
||||
DeleteMessage(ctx context.Context, id string) error
|
||||
// RenameSession sets a session's display title. Both the main session and sub
|
||||
// sessions may be renamed (a title is display-only metadata); an unknown id
|
||||
// returns ErrSessionNotFound.
|
||||
// RenameSession 设置会话的显示标题。主会话与子会话都可重命名(标题只是
|
||||
// 展示元数据);未知 id 返回 ErrSessionNotFound。
|
||||
RenameSession(ctx context.Context, sessionID, title string) error
|
||||
// SetPinned pins or unpins a session. Pinning is display/order metadata only;
|
||||
// both the main session and sub sessions may be pinned. An unknown id returns
|
||||
// ErrSessionNotFound.
|
||||
// SetPinned 置顶或取消置顶一个会话。置顶只是展示/排序元数据;主会话与
|
||||
// 子会话都可置顶。未知 id 返回 ErrSessionNotFound。
|
||||
SetPinned(ctx context.Context, sessionID string, pinned bool) error
|
||||
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid).
|
||||
// It binds no explicit role, so the new session inherits the global default
|
||||
// (persona_id = ''); *Store additionally offers NewSubSessionFor for the
|
||||
// role-bound variant (design/22 §4.1), which this narrow consumer-side interface
|
||||
// deliberately omits because neither channelbridge nor backthink ever creates a
|
||||
// session.
|
||||
// NewSubSession 创建一个子会话并返回其 id(MainSessionID:uuid)。它不绑定
|
||||
// 显式角色,因此新会话继承全局默认(persona_id 为空);*Store 另提供
|
||||
// NewSubSessionFor 这个绑定角色的变体(design/22 §4.1),本窄接口刻意
|
||||
// 省略它,因为 channelbridge 与 backthink 都不创建会话。
|
||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||
// ListSessions returns pinned sessions first, then the main session, then every
|
||||
// sub session oldest first.
|
||||
// ListSessions 先返回置顶会话,再返回主会话,最后返回每个子会话(最旧
|
||||
// 优先)。
|
||||
ListSessions(ctx context.Context) ([]SessionMeta, error)
|
||||
}
|
||||
|
||||
// Store is the SQLite-backed chat-history store. It owns the chat_sessions and
|
||||
// chat_messages tables. Concurrency is delegated to *sql.DB (safe for concurrent use),
|
||||
// so Store holds no further lock. Every query is parameterized and reads list columns
|
||||
// explicitly (no SELECT *); reads take a context.
|
||||
// Store 是基于 SQLite 的聊天历史存储。它拥有 chat_sessions 与 chat_messages
|
||||
// 两张表。并发交给 *sql.DB(可安全并发使用),因此 Store 不再持有额外的锁。
|
||||
// 每条查询都参数化,读取时显式列出列(不用 SELECT *);读取都接受 context。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// rowScanner is satisfied by both *sql.Row and *sql.Rows, so a single scanMessage
|
||||
// helper serves List and Recent.
|
||||
// rowScanner 由 *sql.Row 与 *sql.Rows 同时满足,因此单个 scanMessage 辅助函数
|
||||
// 就能服务 List 与 Recent。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// NewStore builds a Store on db, ensuring the chat_sessions/chat_messages schema exists
|
||||
// and that the fixed main session row is present. The DDL mirrors the 0003 migration;
|
||||
// it is idempotent so the store is self-contained and usable from any *sql.DB, matching
|
||||
// the memory and session packages. db must be live; nil returns ErrNilDB. log may be nil.
|
||||
// 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
|
||||
@@ -269,24 +245,23 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Append persists msg, assigning a fresh id and a now-ms CreatedAt when the caller left
|
||||
// either empty/zero. It returns the error wrapped with the operation name. Callers that
|
||||
// need the generated id/timestamp back should use AppendFull instead.
|
||||
// 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 persists msg like Append but returns the complete stored message — the id
|
||||
// and CreatedAt the store assigned when the caller left them empty/zero — so a caller can
|
||||
// broadcast it for cross-end realtime sync (design/16 §4.2) without a second read.
|
||||
// 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 is the shared Append/AppendFull body. It assigns a fresh id and a now-ms
|
||||
// CreatedAt when the caller left either empty/zero, then inserts the row and returns the
|
||||
// resulting message.
|
||||
// insert 是 Append/AppendFull 共用的主体。它在调用方把 id/CreatedAt 留空/留零时
|
||||
// 分配新的 id 与现在的毫秒时间戳,然后插入该行并返回得到的消息。
|
||||
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
if msg.ID == "" {
|
||||
id, err := newID()
|
||||
@@ -315,13 +290,11 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// List returns up to limit messages for sessionID, newest first. When before is nonzero
|
||||
// it bounds the query to messages strictly older than before, so a client can page upward
|
||||
// by passing the oldest message's created_at from the previous page. When platform is
|
||||
// non-empty it further restricts the page to messages carrying that source platform
|
||||
// (design/16 §4.4: the Web client omits platform for the full main session, an IM
|
||||
// adapter passes its own platform to see only its lane). The returned slice is non-nil.
|
||||
// limit is clamped to [1, maxPageLimit].
|
||||
// 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
|
||||
@@ -358,11 +331,10 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// ListPlatformLog returns up to limit messages for platform across every non-main
|
||||
// (isolated) session, newest first. Main-session messages are excluded so only the
|
||||
// platform's own message stream (groups + stranger private chats) is surfaced to the
|
||||
// adapter log page (design/16 §4.6 §4). before is a millisecond cursor; when nonzero
|
||||
// only strictly older messages are returned. The returned slice is non-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
|
||||
@@ -395,13 +367,11 @@ func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// ListAdapterLog returns up to limit messages for a single IM adapter (platform +
|
||||
// account registration name, e.g. "kook:3bef..."), newest first, for the per-adapter
|
||||
// log page. It does NOT exclude the main web session: an owner-private IM chat lives
|
||||
// in the main session but still carries the adapter_id, so a per-adapter log includes
|
||||
// the owner mirror. An empty adapterID returns an empty (non-nil) slice. before is a
|
||||
// millisecond cursor; when nonzero only strictly older messages are returned. The
|
||||
// returned slice is non-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 == "" {
|
||||
@@ -437,10 +407,9 @@ func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// ListAllLog returns up to limit messages across every IM adapter source
|
||||
// (adapter_id != ''), newest first, for the adapter overview page. before is a
|
||||
// millisecond cursor; when nonzero only strictly older messages are returned. The
|
||||
// returned slice is non-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
|
||||
@@ -473,8 +442,8 @@ func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Mess
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// Recent returns the most recent n messages for sessionID in oldest-first order (the
|
||||
// tail-most window), for WS replay / context tail. The returned slice is non-nil.
|
||||
// Recent 按最旧优先返回 sessionID 最近的 n 条消息(最靠尾部的那段窗口),供
|
||||
// WS 回放 / 上下文尾部使用。返回切片非 nil。
|
||||
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
|
||||
if n < 1 {
|
||||
n = defaultPageLimit
|
||||
@@ -486,8 +455,7 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
// Recent returns newest-last so the window is in chronological order; build it and
|
||||
// reverse in place.
|
||||
// Recent 让最新的排在最后,使窗口处于时间顺序;先按查询顺序构建再原地反转。
|
||||
msgs := make([]Message, 0)
|
||||
for rows.Next() {
|
||||
m, err := scanMessage(rows)
|
||||
@@ -505,8 +473,8 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a sub session and all its messages in one transaction. The fixed
|
||||
// main session is immutable and returns ErrImmutableMain (design/16 §6).
|
||||
// DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
|
||||
// 返回 ErrImmutableMain(design/16 §6)。
|
||||
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
if sessionID == MainSessionID {
|
||||
return ErrImmutableMain
|
||||
@@ -529,17 +497,16 @@ func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSubSession creates a sub session with no explicit role and returns its id
|
||||
// (MainSessionID:uuid). The new session therefore inherits the global default role
|
||||
// (persona_id = ''); use NewSubSessionFor to bind a role at creation time. ownerID
|
||||
// is accepted for interface completeness (the Web owner is identified by JWT, not
|
||||
// stored per session); it is logged at debug and not persisted.
|
||||
// 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 inserts the fixed main session row when it is absent, so ListSessions
|
||||
// always reports the main session even before any message is written.
|
||||
// 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())
|
||||
@@ -549,7 +516,7 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanMessage reads one chat_messages row. message columns are scanned positionally.
|
||||
// scanMessage 读取一行 chat_messages。消息各列按位置扫描。
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, platform, deviceID, senderName, adapterID string
|
||||
@@ -560,8 +527,8 @@ func scanMessage(sc rowScanner) (Message, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// clampLimit bounds limit to [1, maxPageLimit]; a caller-supplied 0 or negative falls back
|
||||
// to defaultPageLimit.
|
||||
// clampLimit 把 limit 钳制到 [1, maxPageLimit];调用方传入的 0 或负数回退到
|
||||
// defaultPageLimit。
|
||||
func clampLimit(limit int) int {
|
||||
if limit < 1 {
|
||||
return defaultPageLimit
|
||||
@@ -572,8 +539,8 @@ func clampLimit(limit int) int {
|
||||
return limit
|
||||
}
|
||||
|
||||
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a UUID
|
||||
// dependency so the package stays self-contained.
|
||||
// newID 返回一个 128 位密码学随机的 id,以十六进制编码。它避免引入 UUID 依赖,
|
||||
// 使本包保持自足。
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -582,7 +549,7 @@ func newID() (string, error) {
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
|
||||
// discardLogger 在 log 非 nil 时返回 log,否则返回一个什么都不写的日志器。
|
||||
func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
if log != nil {
|
||||
return log
|
||||
@@ -590,9 +557,8 @@ func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// Schema DDL. The 0003 migration in internal/db is the authoritative mechanism for
|
||||
// domain tables, but it is intentionally also re-issued here idempotently so the store is
|
||||
// self-contained and usable from any *sql.DB (matching the memory and session packages).
|
||||
// Schema DDL。internal/db 里的 0003 迁移是领域表的权威机制,但这里刻意再幂等
|
||||
// 地发出一次,使存储自足、可从任意 *sql.DB 使用(与 memory 和 session 包一致)。
|
||||
const createSessionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
Reference in new issue
Block a user