docs(comments): 注释中文化 · internal/runtime/history(15 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 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:
AskaEth committed 2026-10-04 12:28:48 +08:00
1 parent b0fbb8bc0d
commit 09fba77f87
15 files changed
+430 -516

No files matched your search

+6 -9
View File
@@ -5,15 +5,12 @@ import (
"fmt" "fmt"
) )
// ListAfter returns up to limit messages for sessionID strictly newer than after // ListAfter 按最旧优先返回 sessionID 中严格新于 after(created_at > after)的
// (created_at > after) in oldest-first order, for forward incremental catch-up: a // 至多 limit 条消息,用于前向增量追赶:重连的客户端传入自己已见过的最新
// client that reconnected passes the newest createdAt it has already seen and pulls // createdAt,只拉取错过的部分,无论有多少条("before" 游标只能向后走,无法
// only what it missed, however many messages that is (the "before" cursor only walks // 恢复大于一页的空洞)。platform 非空时把该页进一步限制为携带该来源平台的
// backwards and cannot recover a gap larger than one page). When platform is non-empty // 消息,与 List 完全一致。rowid 打破 created_at 的并列,使同一毫秒写入的
// the page is further restricted to messages carrying that source platform, exactly as // 消息保持插入顺序。返回切片非 nil;limit 钳制到 [1, maxPageLimit]。
// in List. rowid breaks created_at ties so messages written in the same millisecond
// keep their insertion order. The returned slice is non-nil; limit is clamped to
// [1, maxPageLimit].
func (s *Store) ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error) { func (s *Store) ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit) 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 query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
+11 -12
View File
@@ -5,8 +5,8 @@ import (
"testing" "testing"
) )
// TestListAfterAscendingExclusive asserts the forward cursor returns only messages // TestListAfterAscendingExclusive 断言前向游标只返回严格新于 after 的消息,且按
// strictly newer than after, in oldest-first order (the reconnect catch-up direction). // 最旧优先排序(重连追赶方向)。
func TestListAfterAscendingExclusive(t *testing.T) { func TestListAfterAscendingExclusive(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -25,7 +25,7 @@ func TestListAfterAscendingExclusive(t *testing.T) {
t.Fatalf("after=2000 = %+v, want ascending [3000 4000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt}) t.Fatalf("after=2000 = %+v, want ascending [3000 4000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt})
} }
// after at the newest message yields an empty (non-nil) page. // after 落在最新消息处时得到空(非 nil)的一页。
msgs, err = st.ListAfter(ctx, MainSessionID, 4000, 50, "") msgs, err = st.ListAfter(ctx, MainSessionID, 4000, 50, "")
if err != nil { if err != nil {
t.Fatalf("list after newest: %v", err) t.Fatalf("list after newest: %v", err)
@@ -34,7 +34,7 @@ func TestListAfterAscendingExclusive(t *testing.T) {
t.Fatalf("after=4000 = %+v (nil=%v), want empty non-nil", msgs, msgs == nil) t.Fatalf("after=4000 = %+v (nil=%v), want empty non-nil", msgs, msgs == nil)
} }
// after=0 is the whole session, still ascending. // after=0 即整个会话,仍为升序。
msgs, err = st.ListAfter(ctx, MainSessionID, 0, 50, "") msgs, err = st.ListAfter(ctx, MainSessionID, 0, 50, "")
if err != nil { if err != nil {
t.Fatalf("list after 0: %v", err) t.Fatalf("list after 0: %v", err)
@@ -44,9 +44,8 @@ func TestListAfterAscendingExclusive(t *testing.T) {
} }
} }
// TestListAfterSameTimestampRowIDOrder asserts messages sharing a created_at keep their // TestListAfterSameTimestampRowIDOrder 断言共享同一 created_at 的消息保持插入
// insertion order, so a same-millisecond batch is never reordered or dropped at a page // 顺序,使同一毫秒的一批消息在分页边界处既不会被重排也不会被丢弃(rowid 破并列)。
// boundary (rowid tiebreak).
func TestListAfterSameTimestampRowIDOrder(t *testing.T) { func TestListAfterSameTimestampRowIDOrder(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -65,8 +64,8 @@ func TestListAfterSameTimestampRowIDOrder(t *testing.T) {
} }
} }
// TestListAfterPlatformFilter asserts the optional platform filter applies to the forward // TestListAfterPlatformFilter 断言可选的平台过滤在前向查询上的作用与后向查询完全
// query exactly as it does to the backward one. // 一致。
func TestListAfterPlatformFilter(t *testing.T) { func TestListAfterPlatformFilter(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -90,8 +89,8 @@ func TestListAfterPlatformFilter(t *testing.T) {
} }
} }
// TestListAfterLimitClamp asserts a client-supplied limit is capped at maxPageLimit and // TestListAfterLimitClamp 断言客户端传入的 limit 被钳制到 maxPageLimit,且推进
// that advancing the cursor pages forward without gaps. // 游标能无缝隙地向前翻页。
func TestListAfterLimitClamp(t *testing.T) { func TestListAfterLimitClamp(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -115,7 +114,7 @@ func TestListAfterLimitClamp(t *testing.T) {
t.Fatalf("clamped page bounds = [%d..%d]", page[0].CreatedAt, page[len(page)-1].CreatedAt) t.Fatalf("clamped page bounds = [%d..%d]", page[0].CreatedAt, page[len(page)-1].CreatedAt)
} }
// Advance the cursor to the last seen createdAt: the remaining messages, no gap. // 把游标推进到最后见过的 createdAt:拿到剩余消息,无缝隙。
next, err := st.ListAfter(ctx, MainSessionID, page[len(page)-1].CreatedAt, 999, "") next, err := st.ListAfter(ctx, MainSessionID, page[len(page)-1].CreatedAt, 999, "")
if err != nil { if err != nil {
t.Fatalf("list after next: %v", err) t.Fatalf("list after next: %v", err)
+21 -26
View File
@@ -8,20 +8,18 @@ import (
"strings" "strings"
) )
// This file owns the single-message operations behind the Android collaboration batch // 本文件负责 Android 协同批次 ③a 背后的单条消息操作(D2 搜索、D3 编辑/删除),
// ③a (D2 search, D3 edit/delete) plus the id-scoped read the D delete broadcast needs. // 以及 D 删除广播所需的按 id 读取。它们都严格限定在会话作用域或 id 作用域:
// All are strictly session-scoped or id-scoped: a message is only ever reached through // 消息只能经由它所属的会话(Search)或它自己不透明的 id(GetMessage/
// the session it belongs to (Search) or its own opaque id (GetMessage/UpdateMessage/ // UpdateMessage/DeleteMessage)到达,因此隔离的群聊/陌生人会话绝不浮现在
// DeleteMessage), so an isolated group/stranger session can never surface in another // 另一个会话的结果中(项目红线)。
// session's results (project red line).
// Search returns up to limit messages for sessionID whose content contains query, // Search 按最新优先返回 sessionID 中内容包含 query 的至多 limit 条消息。匹配
// newest first. The match is a case-insensitive ASCII LIKE (SQLite's default for // 是 ASCII 大小写不敏感的 LIKE(SQLite 对 LIKE 的默认行为),并把 query 中的
// LIKE) with the query's %/_/\ escaped so they match literally; before is the same // %/_/\ 转义使它们按字面匹配;before 与 List 是同一个毫秒游标(严格早于
// millisecond cursor as List (strictly older than before) and limit is clamped to // before),limit 钳制到 [1, maxPageLimit]。rowid 打破 created_at 的并列,使
// [1, maxPageLimit]. rowid breaks created_at ties so same-millisecond messages keep a // 同一毫秒的消息保持稳定顺序。空 query 返回空(非 nil)切片 —— 调用方改用
// stable order. An empty query returns an empty (non-nil) slice — the caller uses // List 做普通翻页。Search 绝不跨越会话边界。
// List for plain paging instead. Search never crosses the session boundary.
func (s *Store) Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error) { func (s *Store) Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit) limit = clampLimit(limit)
if query == "" { if query == "" {
@@ -57,10 +55,9 @@ func (s *Store) Search(ctx context.Context, sessionID, query string, before int6
return msgs, nil return msgs, nil
} }
// UpdateMessage replaces the content of the message with id and returns the updated // UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull
// message (like AppendFull, so the caller can return/broadcast it without a second // 一样,使调用方能无需二次读取就返回/广播它)。只有 content 变化;role、
// read). Only content changes; role, platform, timestamps and the rest stay as stored. // platform、时间戳及其余字段保持已存值。未知 id 返回 ErrMessageNotFound。
// An unknown id returns ErrMessageNotFound.
func (s *Store) UpdateMessage(ctx context.Context, id, content string) (Message, error) { func (s *Store) UpdateMessage(ctx context.Context, id, content string) (Message, error) {
res, err := s.db.ExecContext(ctx, `UPDATE chat_messages SET content = ? WHERE id = ?`, content, id) res, err := s.db.ExecContext(ctx, `UPDATE chat_messages SET content = ? WHERE id = ?`, content, id)
if err != nil { if err != nil {
@@ -76,7 +73,7 @@ func (s *Store) UpdateMessage(ctx context.Context, id, content string) (Message,
return s.messageByID(ctx, id) return s.messageByID(ctx, id)
} }
// DeleteMessage removes a single message. An unknown id returns ErrMessageNotFound. // DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。
func (s *Store) DeleteMessage(ctx context.Context, id string) error { func (s *Store) DeleteMessage(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM chat_messages WHERE id = ?`, id) res, err := s.db.ExecContext(ctx, `DELETE FROM chat_messages WHERE id = ?`, id)
if err != nil { if err != nil {
@@ -92,16 +89,14 @@ func (s *Store) DeleteMessage(ctx context.Context, id string) error {
return nil return nil
} }
// GetMessage reads the message with id, returning ErrMessageNotFound when no such // GetMessage 读取 id 所指消息,没有该行时返回 ErrMessageNotFound。它是传输层
// row exists. It is the id-scoped read the transport uses before removing a // 在移除消息之前使用的按 id 读取:DeleteMessage 之后已无从再问,而那条告知
// message: DeleteMessage leaves nothing to ask afterwards, yet the broadcast that // 其他客户端丢弃该行的广播是按会话划分的。
// tells other clients to drop the row is session-scoped.
func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) { func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) {
return s.messageByID(ctx, id) return s.messageByID(ctx, id)
} }
// messageByID reads one message by id, returning ErrMessageNotFound when the row is // messageByID 按 id 读取一条消息,行不存在时返回 ErrMessageNotFound。
// absent.
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) { func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at row := s.db.QueryRowContext(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 id = ?`, id) FROM chat_messages WHERE id = ?`, id)
@@ -115,8 +110,8 @@ func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
return m, nil return m, nil
} }
// escapeLike escapes the LIKE wildcards in a user query so %, _ and \ match literally // escapeLike 转义用户查询中的 LIKE 通配符,使 %、_ 与 \ 按字面匹配(调用方
// (the caller pairs it with ESCAPE '\'). It is applied before the %...% wrapping. // 配合 ESCAPE '\' 使用)。它在 %...% 包裹之前应用。
func escapeLike(query string) string { func escapeLike(query string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query) return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
} }
+17 -18
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
) )
// seedMessages appends msgs and returns them with their store-assigned ids. // seedMessages 追加 msgs 并返回它们(带存储分配的 id)。
func seedMessages(t *testing.T, st *Store, msgs ...Message) []Message { func seedMessages(t *testing.T, st *Store, msgs ...Message) []Message {
t.Helper() t.Helper()
out := make([]Message, 0, len(msgs)) out := make([]Message, 0, len(msgs))
@@ -31,7 +31,7 @@ func TestSearchSessionScopedNewestFirstAndPaged(t *testing.T) {
Message{SessionID: MainSessionID, Role: RoleUser, Content: "hit four", CreatedAt: 4000}, Message{SessionID: MainSessionID, Role: RoleUser, Content: "hit four", CreatedAt: 4000},
) )
// Newest-first page of two hits; the non-matching message is skipped. // 最新优先、两条命中的一页;不匹配的消息被跳过。
page, err := st.Search(ctx, MainSessionID, "hit", 0, 2) page, err := st.Search(ctx, MainSessionID, "hit", 0, 2)
if err != nil { if err != nil {
t.Fatalf("search: %v", err) t.Fatalf("search: %v", err)
@@ -40,7 +40,7 @@ func TestSearchSessionScopedNewestFirstAndPaged(t *testing.T) {
t.Fatalf("page = %+v, want [4000 3000]", page) t.Fatalf("page = %+v, want [4000 3000]", page)
} }
// before cursor walks to the older hits. // before 游标走到更旧的命中。
older, err := st.Search(ctx, MainSessionID, "hit", 3000, 2) older, err := st.Search(ctx, MainSessionID, "hit", 3000, 2)
if err != nil { if err != nil {
t.Fatalf("search older: %v", err) t.Fatalf("search older: %v", err)
@@ -49,7 +49,7 @@ func TestSearchSessionScopedNewestFirstAndPaged(t *testing.T) {
t.Fatalf("older = %+v, want [2000 1000]", older) t.Fatalf("older = %+v, want [2000 1000]", older)
} }
// An empty query yields an empty (non-nil) slice, not the whole session. // 空查询得到空(非 nil)切片,而不是整个会话。
empty, err := st.Search(ctx, MainSessionID, "", 0, 10) empty, err := st.Search(ctx, MainSessionID, "", 0, 10)
if err != nil { if err != nil {
t.Fatalf("search empty: %v", err) t.Fatalf("search empty: %v", err)
@@ -69,7 +69,7 @@ func TestSearchCaseInsensitiveAndLiteralWildcards(t *testing.T) {
Message{SessionID: MainSessionID, Role: RoleUser, Content: "under_score", CreatedAt: 3000}, Message{SessionID: MainSessionID, Role: RoleUser, Content: "under_score", CreatedAt: 3000},
) )
// ASCII case-insensitive LIKE. // ASCII 大小写不敏感的 LIKE。
got, err := st.Search(ctx, MainSessionID, "hello", 0, 10) got, err := st.Search(ctx, MainSessionID, "hello", 0, 10)
if err != nil { if err != nil {
t.Fatalf("search hello: %v", err) t.Fatalf("search hello: %v", err)
@@ -78,7 +78,7 @@ func TestSearchCaseInsensitiveAndLiteralWildcards(t *testing.T) {
t.Fatalf("hello = %+v, want [Hello World]", got) t.Fatalf("hello = %+v, want [Hello World]", got)
} }
// A literal % must not act as a wildcard (else it would match every row). // 字面 % 不得充当通配符(否则它会匹配每一行)。
pct, err := st.Search(ctx, MainSessionID, "100%", 0, 10) pct, err := st.Search(ctx, MainSessionID, "100%", 0, 10)
if err != nil { if err != nil {
t.Fatalf("search 100%%: %v", err) t.Fatalf("search 100%%: %v", err)
@@ -87,7 +87,7 @@ func TestSearchCaseInsensitiveAndLiteralWildcards(t *testing.T) {
t.Fatalf("100%% = %+v, want [100%% done]", pct) t.Fatalf("100%% = %+v, want [100%% done]", pct)
} }
// A literal _ must match only the underscore, not any single character. // 字面 _ 只能匹配下划线,不能匹配任意单字符。
us, err := st.Search(ctx, MainSessionID, "under_", 0, 10) us, err := st.Search(ctx, MainSessionID, "under_", 0, 10)
if err != nil { if err != nil {
t.Fatalf("search under_: %v", err) t.Fatalf("search under_: %v", err)
@@ -97,9 +97,8 @@ func TestSearchCaseInsensitiveAndLiteralWildcards(t *testing.T) {
} }
} }
// TestSearchNeverCrossesSessionBoundary is the red-line guard: a search is scoped to // TestSearchNeverCrossesSessionBoundary 是红线守卫:搜索限定在单个会话内,因此
// one session, so an isolated group/stranger session's content can never surface in // 隔离的群聊/陌生人会话内容绝不浮现在主会话的结果里(反之亦然)。
// the main session's results (or vice versa).
func TestSearchNeverCrossesSessionBoundary(t *testing.T) { func TestSearchNeverCrossesSessionBoundary(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -144,7 +143,7 @@ func TestUpdateMessageContentOnly(t *testing.T) {
t.Fatalf("updated = %+v, want content=after with role/ts/session preserved", got) t.Fatalf("updated = %+v, want content=after with role/ts/session preserved", got)
} }
// The edit is persisted, not just echoed. // 编辑要落盘,不能只是回显。
listed, err := st.List(ctx, MainSessionID, 0, 10, "") listed, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
@@ -153,7 +152,7 @@ func TestUpdateMessageContentOnly(t *testing.T) {
t.Fatalf("listed = %+v, want content=after", listed) t.Fatalf("listed = %+v, want content=after", listed)
} }
// An unknown id returns the sentinel. // 未知 id 返回哨兵错误。
if _, err := st.UpdateMessage(ctx, "missing", "x"); !errors.Is(err, ErrMessageNotFound) { if _, err := st.UpdateMessage(ctx, "missing", "x"); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("update missing err = %v, want ErrMessageNotFound", err) t.Fatalf("update missing err = %v, want ErrMessageNotFound", err)
} }
@@ -196,10 +195,10 @@ func TestSetPinnedOrdersSessions(t *testing.T) {
t.Fatalf("new sub 2: %v", err) t.Fatalf("new sub 2: %v", err)
} }
// Baseline: main first, subs oldest-first. // 基线:主会话在前,子会话最旧优先。
assertSessionOrder(t, st, []string{MainSessionID, first, second}) assertSessionOrder(t, st, []string{MainSessionID, first, second})
// Pinning the newest sub floats it above the main session. // 置顶最新的子会话会把它浮到主会话之上。
if err := st.SetPinned(ctx, second, true); err != nil { if err := st.SetPinned(ctx, second, true); err != nil {
t.Fatalf("pin: %v", err) t.Fatalf("pin: %v", err)
} }
@@ -208,19 +207,19 @@ func TestSetPinnedOrdersSessions(t *testing.T) {
t.Fatalf("pinned session meta = %+v, want Pinned=true", metas[0]) t.Fatalf("pinned session meta = %+v, want Pinned=true", metas[0])
} }
// Unpinning restores the baseline order. // 取消置顶恢复基线顺序。
if err := st.SetPinned(ctx, second, false); err != nil { if err := st.SetPinned(ctx, second, false); err != nil {
t.Fatalf("unpin: %v", err) t.Fatalf("unpin: %v", err)
} }
assertSessionOrder(t, st, []string{MainSessionID, first, second}) assertSessionOrder(t, st, []string{MainSessionID, first, second})
// An unknown session id is a not-found error. // 未知会话 id 是 not-found 错误。
if err := st.SetPinned(ctx, "web:missing", true); !errors.Is(err, ErrSessionNotFound) { if err := st.SetPinned(ctx, "web:missing", true); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("pin missing err = %v, want ErrSessionNotFound", err) t.Fatalf("pin missing err = %v, want ErrSessionNotFound", err)
} }
} }
// listMetas returns ListSessions output for order/pin assertions. // listMetas 返回 ListSessions 的结果,用于顺序/置顶断言。
func listMetas(t *testing.T, st *Store) []SessionMeta { func listMetas(t *testing.T, st *Store) []SessionMeta {
t.Helper() t.Helper()
metas, err := st.ListSessions(context.Background()) metas, err := st.ListSessions(context.Background())
@@ -230,7 +229,7 @@ func listMetas(t *testing.T, st *Store) []SessionMeta {
return metas return metas
} }
// assertSessionOrder fails unless ListSessions returns exactly ids in order. // assertSessionOrder 在 ListSessions 没有严格按 ids 的顺序返回时使测试失败。
func assertSessionOrder(t *testing.T, st *Store, ids []string) { func assertSessionOrder(t *testing.T, st *Store, ids []string) {
t.Helper() t.Helper()
metas := listMetas(t, st) metas := listMetas(t, st)
+19 -24
View File
@@ -9,22 +9,20 @@ import (
"time" "time"
) )
// This file owns the per-session role binding (DevDocs/design/22 多角色并存 + // 本文件负责每会话的角色绑定(DevDocs/design/22 多角色并存 +
// 按会话选角 §2/§4.1). A session's role is the single source of truth for "which // 按会话选角 §2/§4.1)。一个会话的角色是「这个对话里由哪个角色应答」的唯一
// role answers in this conversation": the loop resolves the system prompt, the // 事实来源:循环据此解析系统提示词、记忆作用域、情绪机与思考预算。该值只是
// memory scope, the emotion machine and the thinking budget from it. The value // 展示/作用域元数据 —— 身份与权限仍由渠道 actor 元数据决定(项目红线)。
// is display/scope metadata only — identity and permissions are still decided by
// the channel actor metadata (project red line).
// //
// '' means "inherit the current global default role", so it is both the column // 空意味着「继承当前全局默认角色」,因此它既是列默认值,也是一个合法的显式
// default and a legal explicit value: setting it clears an explicit binding and // 取值:把它设为空即清除显式绑定,让会话重新跟随默认角色变更
// lets the session follow a default-role change again (design/22 §7-6). // (design/22 §7-6)。
// NewSubSessionFor creates a sub session bound to personaID and returns its id // NewSubSessionFor 创建绑定到 personaID 的子会话并返回其 id
// (MainSessionID:uuid). A blank personaID binds no explicit role, so the session // (MainSessionID:uuid)。personaID 为空则不绑定任何显式角色,会话因此继承
// inherits the global default — the same behavior as NewSubSession. ownerID is // 全局默认 —— 与 NewSubSession 行为相同。ownerID 为与 NewSubSession 对称而
// accepted for symmetry with NewSubSession (the Web owner is identified by JWT, // 接受(Web 主人由 JWT 识别,不按会话存储);它只以 debug 级别记日志,不被
// not stored per session); it is logged at debug and not persisted. // 持久化。
func (s *Store) NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) { func (s *Store) NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) {
id, err := newID() id, err := newID()
if err != nil { if err != nil {
@@ -42,11 +40,10 @@ func (s *Store) NewSubSessionFor(ctx context.Context, ownerID, personaID string)
return sessionID, nil return sessionID, nil
} }
// SetSessionPersona binds (or clears, when personaID is blank) a session's role. // SetSessionPersona 绑定(或在 personaID 为空时清除)一个会话的角色。固定主
// Both the fixed main session and sub sessions may be retargeted: the binding is // 会话与子会话都可被改绑:该绑定是作用域元数据,不是身份。未知 id 返回
// scope metadata, not identity. An unknown id returns ErrSessionNotFound. It // ErrSessionNotFound。它绝不触碰对话:已存消息保留说出它们的那个角色
// never touches the conversation: the stored messages keep the role that spoke // (design/22 §7-5),下一个回合只是解析新角色。
// them (design/22 §7-5), and the next turn simply resolves the new role.
func (s *Store) SetSessionPersona(ctx context.Context, sessionID, personaID string) error { func (s *Store) SetSessionPersona(ctx context.Context, sessionID, personaID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET persona_id = ? WHERE id = ?`, res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET persona_id = ? WHERE id = ?`,
strings.TrimSpace(personaID), sessionID) strings.TrimSpace(personaID), sessionID)
@@ -63,11 +60,9 @@ func (s *Store) SetSessionPersona(ctx context.Context, sessionID, personaID stri
return nil return nil
} }
// SessionPersona returns the role bound to sessionID, or "" when the session // SessionPersona 返回绑定到 sessionID 的角色,会话继承全局默认时返回空。未知
// inherits the global default. An unknown id returns ErrSessionNotFound so a // id 返回 ErrSessionNotFound,使调用方能区分「没有显式角色」(真实会话、绑定
// caller can tell "no explicit role" (a real session, empty binding) from "no // 为空)与「没有这个会话」;只需要有效角色的调用方把两者都当作「用默认」。
// such session"; a caller that only needs the effective role treats both as
// "use the default".
func (s *Store) SessionPersona(ctx context.Context, sessionID string) (string, error) { func (s *Store) SessionPersona(ctx context.Context, sessionID string) (string, error) {
var personaID string var personaID string
err := s.db.QueryRowContext(ctx, `SELECT persona_id FROM chat_sessions WHERE id = ?`, sessionID).Scan(&personaID) err := s.db.QueryRowContext(ctx, `SELECT persona_id FROM chat_sessions WHERE id = ?`, sessionID).Scan(&personaID)
@@ -6,8 +6,7 @@ import (
"testing" "testing"
) )
// personaOf returns the stored role binding of a session, failing the test when the // personaOf 返回某个会话已存储的角色绑定,会话不在列表中时使测试失败。
// session is absent from the list.
func personaOf(t *testing.T, st *Store, id string) string { func personaOf(t *testing.T, st *Store, id string) string {
t.Helper() t.Helper()
sessions, err := st.ListSessions(context.Background()) sessions, err := st.ListSessions(context.Background())
@@ -23,11 +22,10 @@ func personaOf(t *testing.T, st *Store, id string) string {
return "" return ""
} }
// TestSessionPersonaRoundTrip is design/22 acceptance 1: the session-level role is // TestSessionPersonaRoundTrip 是 design/22 验收 1:会话级角色是唯一事实来源,
// the single source of truth, so a value written through SetSessionPersona must come // 因此通过 SetSessionPersona 写入的值必须能从直接读取与列表投影(客户端拿到的
// back from both the direct read and the list projection (the DTO the client gets), // DTO)两条路径读回,而始终存在的主会话初始为未绑定(空 = 继承全局默认)。
// and the main session — which always exists — starts out unbound ('' = inherit the // 显式空串会把绑定清回同一状态。
// global default). An explicit "" clears the binding back to that same state.
func TestSessionPersonaRoundTrip(t *testing.T) { func TestSessionPersonaRoundTrip(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -52,8 +50,7 @@ func TestSessionPersonaRoundTrip(t *testing.T) {
} }
} }
// An explicit empty value is the documented "unbind" (follow the default again), // 显式空值是文档化的「解绑」(重新跟随默认),不是空操作。
// not a no-op.
if err := st.SetSessionPersona(ctx, MainSessionID, ""); err != nil { if err := st.SetSessionPersona(ctx, MainSessionID, ""); err != nil {
t.Fatalf("SetSessionPersona(\"\"): %v", err) t.Fatalf("SetSessionPersona(\"\"): %v", err)
} }
@@ -62,9 +59,9 @@ func TestSessionPersonaRoundTrip(t *testing.T) {
} }
} }
// TestSessionPersonaUnknownSession covers the error contract: binding or reading the // TestSessionPersonaUnknownSession 覆盖错误契约:绑定或读取一个不存在的会话的
// role of a session that does not exist reports ErrSessionNotFound (so the HTTP layer // 角色会报 ErrSessionNotFound(因此 HTTP 层回答 404),而不是悄悄创建/查不到
// answers 404) instead of silently creating/looking up nothing. // 任何东西。
func TestSessionPersonaUnknownSession(t *testing.T) { func TestSessionPersonaUnknownSession(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -78,10 +75,9 @@ func TestSessionPersonaUnknownSession(t *testing.T) {
} }
} }
// TestNewSubSessionForBindsPersona locks the create path: a sub session created with a // TestNewSubSessionForBindsPersona 锁定创建路径:带角色创建的子会话从第一次读取
// role carries it from its first read (no extra PATCH needed), and the plain // 起就携带它(无需额外 PATCH),而朴素的 NewSubSession 仍创建未绑定会话 —— 即
// NewSubSession still creates an unbound session — the pre-multi-character contract, // 多角色之前的契约,每个既有调用方都依赖它。
// which every existing caller relies on.
func TestNewSubSessionForBindsPersona(t *testing.T) { func TestNewSubSessionForBindsPersona(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -104,11 +100,10 @@ func TestNewSubSessionForBindsPersona(t *testing.T) {
} }
} }
// TestMessagePersonaPersisted proves design/22 §7-5's storage half: a session may // TestMessagePersonaPersisted 证明 design/22 §7-5 的存储一半:一个会话可以在保留
// switch roles while keeping its context, so every message records the role that // 上下文的同时切换角色,因此每条消息都记录说出它的那个角色。该列通过 REST/WS
// SPOKE it. The column survives the write/read round trip through the same scanner // DTO 使用的同一个扫描器活过写入/读取往返,而未指定角色写入的消息保持为空
// the REST/WS DTOs use, and messages written without a role stay '' (= the default // (= 默认角色),这正是升级前历史仍可读的原因。
// role), which is what keeps pre-upgrade history readable.
func TestMessagePersonaPersisted(t *testing.T) { func TestMessagePersonaPersisted(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -139,8 +134,8 @@ func TestMessagePersonaPersisted(t *testing.T) {
t.Fatalf("unattributed message persona_id = %q, want ''", got) t.Fatalf("unattributed message persona_id = %q, want ''", got)
} }
// The persona column must also survive Recent (the WS replay path) and // persona 列还必须活过 Recent(WS 回放路径)与 AppendFull(编排器的实时
// AppendFull (the orchestrator's realtime-sync path). // 同步路径)。
recent, err := st.Recent(ctx, MainSessionID, 10) recent, err := st.Recent(ctx, MainSessionID, 10)
if err != nil { if err != nil {
t.Fatalf("recent: %v", err) t.Fatalf("recent: %v", err)
+4 -5
View File
@@ -5,10 +5,9 @@ import (
"fmt" "fmt"
) )
// SetPinned pins or unpins a persistent session. Pinning is display/order metadata // SetPinned 置顶或取消置顶一个持久化会话。置顶只是展示/排序元数据(与标题
// only (like the title): both the fixed main session and sub sessions may be pinned, // 一样):固定主会话与子会话都可置顶,身份/权限绝不由它推导。未知 id 返回
// and identity/permissions never derive from it. An unknown id returns // ErrSessionNotFound。
// ErrSessionNotFound.
func (s *Store) SetPinned(ctx context.Context, sessionID string, pinned bool) error { func (s *Store) SetPinned(ctx context.Context, sessionID string, pinned bool) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET pinned = ? WHERE id = ?`, pinnedValue(pinned), sessionID) res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET pinned = ? WHERE id = ?`, pinnedValue(pinned), sessionID)
if err != nil { if err != nil {
@@ -24,7 +23,7 @@ func (s *Store) SetPinned(ctx context.Context, sessionID string, pinned bool) er
return nil return nil
} }
// pinnedValue maps the boolean to SQLite's 0/1 integer representation. // pinnedValue 把布尔值映射为 SQLite 的 0/1 整数表示。
func pinnedValue(pinned bool) int { func pinnedValue(pinned bool) int {
if pinned { if pinned {
return 1 return 1
+26 -33
View File
@@ -7,34 +7,29 @@ import (
"strings" "strings"
) )
// This file owns the two derived per-session display fields carried in the session // 本文件负责会话列表中携带的两个派生展示字段(GET /api/v1/sessions):最后
// list (GET /api/v1/sessions): the last-message preview and the unread count. Both are // 消息预览与未读数。两者都是只读投影,使客户端无需逐会话请求就能渲染对话行
// read-only projections so a client can render a conversation row (with its unread // (及其未读角标)。预览会被截断以便展示,两个字段都绝不参与身份或权限判定
// badge) without one request per session. The preview is truncated for display, and // —— 身份来自渠道 actor 元数据 / JWT(项目红线)。
// neither field ever feeds identity or permission decisions — identity comes from
// channel actor metadata / JWT (project red line).
// lastMessagePreviewRunes bounds the preview content so a long message cannot bloat the // lastMessagePreviewRunes 限制预览内容,使一条长消息不会撑大会话列表响应。
// session-list response. A longer message is whitespace-collapsed and truncated to this // 更长的消息会先折叠空白,再截断到这么多 rune 并加省略号。
// many runes with an ellipsis.
const lastMessagePreviewRunes = 60 const lastMessagePreviewRunes = 60
// MessageSummary is a display-only preview of a session's most recent message. // MessageSummary 是会话最新消息的纯展示预览。
type MessageSummary struct { type MessageSummary struct {
Role string // "user" | "assistant" Role string // "user" | "assistant"(用户 | 助手)
Content string // whitespace-collapsed, truncated preview Content string // 已折叠空白、已截断的预览
CreatedAt int64 // Unix milliseconds CreatedAt int64 // Unix 毫秒
} }
// listSessionsQuery reads every session plus its newest message and its unread count // listSessionsQuery 用一条语句读取每个会话及其最新消息与未读数。LEFT JOIN 的
// in one statement. The LEFT JOIN's correlated subquery resolves each session's newest // 关联子查询经由 (session_id, created_at DESC) 索引解析每个会话最新的那一行
// row via the (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond // (rowid DESC 按插入顺序打破同一毫秒的并列),因此没有消息的会话仍会以预览
// tie by insertion order), so a session with no messages still appears with NULL preview // 列全为 NULL 的形式出现。未读数是同一个索引上的关联 COUNT(*)
// columns. The unread count is a correlated COUNT(*) over the same index // (session_id = s.id AND created_at > s.read_at 是一次等值 + 范围扫描),
// (session_id = s.id AND created_at > s.read_at is an equality + range scan), so no // 因此无需逐会话往返(N+1),也不需要额外索引。排序把置顶会话浮到最前,
// per-session round trip (N+1) is needed and no extra index is required. Ordering floats // 再把主会话排在未置顶子会话之前,然后子会话最旧优先。
// pinned sessions first, then keeps the main session before unpinned subs, then subs
// oldest-first.
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, s.persona_id, const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, s.persona_id,
m.role, m.content, m.created_at, m.role, m.content, m.created_at,
(SELECT COUNT(*) FROM chat_messages c (SELECT COUNT(*) FROM chat_messages c
@@ -48,11 +43,10 @@ const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
) )
ORDER BY s.pinned DESC, (s.type = ?) DESC, s.created_at ASC` ORDER BY s.pinned DESC, (s.type = ?) DESC, s.created_at ASC`
// ListSessions returns pinned sessions first, then the main session, then every sub // ListSessions 按置顶会话在前、其后是主会话、最后是每个子会话(最旧优先)的
// session, oldest first, each carrying a LastMessage preview of its newest message (nil // 顺序返回,每个会话携带其最新消息的 LastMessage 预览(没有消息时为 nil)与
// when the session has none) and the UnreadCount derived from the session's read cursor. // 由会话读游标推导出的 UnreadCount。会话、预览与未读数在单次查询中读出,
// Sessions, previews and unread counts are read in a single query, so the query count // 因此查询数量不随会话数增长。返回切片非 nil。
// does not grow with the number of sessions. The returned slice is non-nil.
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) { func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain) rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain)
if err != nil { if err != nil {
@@ -74,10 +68,9 @@ func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
return sessions, nil return sessions, nil
} }
// scanSessionMeta reads one joined chat_sessions + last-message + unread-count row. The // scanSessionMeta 读取一行已连接的 chat_sessions + 最新消息 + 未读数。从未收到
// three message columns are NULL for a session that has never received a message, so they // 消息的会话那三个消息列为 NULL,因此它们扫描到可空的容器里,只有该行存在时
// scan into nullable holders and only produce a LastMessage when a row is present. The // 才产出 LastMessage。未读数是非 NULL 的 COUNT(*),因此扫描到普通 int。
// unread count is a non-NULL COUNT(*), so it scans into a plain int.
func scanSessionMeta(sc rowScanner) (SessionMeta, error) { func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
var ( var (
meta SessionMeta meta SessionMeta
@@ -102,8 +95,8 @@ func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
return meta, nil return meta, nil
} }
// previewContent collapses whitespace runs (newlines included) to single spaces and // previewContent 把连续空白(含换行)折叠为单个空格,并截断到
// truncates to lastMessagePreviewRunes runes with an ellipsis. It is display-only. // lastMessagePreviewRunes 个 rune 加省略号。它只用于展示。
func previewContent(content string) string { func previewContent(content string) string {
joined := strings.Join(strings.Fields(content), " ") joined := strings.Join(strings.Fields(content), " ")
runes := []rune(joined) runes := []rune(joined)
@@ -6,8 +6,7 @@ import (
"testing" "testing"
) )
// sessionOf returns the SessionMeta of one session from ListSessions, failing the test // sessionOf 从 ListSessions 里返回某个会话的 SessionMeta,会话缺失时使测试失败。
// when the session is absent.
func sessionOf(t *testing.T, st *Store, id string) SessionMeta { func sessionOf(t *testing.T, st *Store, id string) SessionMeta {
t.Helper() t.Helper()
sessions, err := st.ListSessions(context.Background()) sessions, err := st.ListSessions(context.Background())
@@ -28,7 +27,7 @@ func TestListSessionsLastMessagePreview(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// The empty main session has no preview. // 空的主会话没有预览。
if got := sessionOf(t, st, MainSessionID); got.LastMessage != nil { if got := sessionOf(t, st, MainSessionID); got.LastMessage != nil {
t.Fatalf("empty main LastMessage = %+v, want nil", got.LastMessage) t.Fatalf("empty main LastMessage = %+v, want nil", got.LastMessage)
} }
@@ -47,7 +46,7 @@ func TestListSessionsLastMessagePreview(t *testing.T) {
t.Fatalf("append sub: %v", err) t.Fatalf("append sub: %v", err)
} }
// Main stays first and carries its newest message, not the first one. // 主会话仍在最前,携带它最新的消息,而不是第一条。
sessions, err := st.ListSessions(ctx) sessions, err := st.ListSessions(ctx)
if err != nil { if err != nil {
t.Fatalf("list sessions: %v", err) t.Fatalf("list sessions: %v", err)
@@ -64,7 +63,7 @@ func TestListSessionsLastMessagePreview(t *testing.T) {
t.Fatalf("sub LastMessage = %+v, want user/sub question/1500", subPreview) t.Fatalf("sub LastMessage = %+v, want user/sub question/1500", subPreview)
} }
// A sub session created after the main one but never written has no preview. // 在主会话之后创建、但从未写入的子会话没有预览。
empty, err := st.NewSubSession(ctx, "owner") empty, err := st.NewSubSession(ctx, "owner")
if err != nil { if err != nil {
t.Fatalf("new empty sub: %v", err) t.Fatalf("new empty sub: %v", err)
@@ -88,8 +87,7 @@ func TestListSessionsLastMessageTruncatesAndCollapses(t *testing.T) {
t.Fatalf("truncated preview = %+v, want %q", got, want) t.Fatalf("truncated preview = %+v, want %q", got, want)
} }
// Whitespace runs (including newlines) collapse to single spaces, and a short // 连续空白(含换行)折叠为单个空格,短消息原样返回。
// message is returned whole.
sub, err := st.NewSubSession(ctx, "owner") sub, err := st.NewSubSession(ctx, "owner")
if err != nil { if err != nil {
t.Fatalf("new sub: %v", err) t.Fatalf("new sub: %v", err)
@@ -107,7 +105,7 @@ func TestListSessionsLastMessageTieBreaksByInsertion(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// Two messages sharing a millisecond timestamp: the later insert is the newest. // 两条消息共享同一毫秒时间戳:后插入的是最新的。
for _, content := range []string{"older", "newer"} { for _, content := range []string{"older", "newer"} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: content, CreatedAt: 5000}); err != nil { if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: content, CreatedAt: 5000}); err != nil {
t.Fatalf("append %s: %v", content, err) t.Fatalf("append %s: %v", content, err)
+13 -17
View File
@@ -6,26 +6,22 @@ import (
"time" "time"
) )
// This file owns the per-session read cursor (design/16 §4.2, Android 协同批次 // 本文件负责每会话的读游标(design/16 §4.2,Android 协同批次 D4/B3)。本层
// D4/B3). There is a single owner account and no per-device identity at this // 只有一个主人账号、没有按设备的身份,因此游标是会话作用域的:在任一端已读
// layer, so the cursor is session-scoped: reading on any one end marks the session // 即为该账号的每一端把该会话标为已读。游标存放在 chat_sessions.read_at,与
// read for every end of the account. The cursor lives on chat_sessions.read_at in // chat_messages.created_at 同为毫秒单位,因此未读角标就是一个朴素的
// the same millisecond unit as chat_messages.created_at, so the unread badge is a // `created_at > read_at` 计数(见 listSessionsQuery)。
// plain `created_at > read_at` count (see listSessionsQuery).
// MarkSessionRead advances the session's read cursor to before and returns the // MarkSessionRead 把会话的读游标推进到 before,并把结果游标作为 Unix 毫秒
// resulting cursor as a Unix millisecond timestamp. A zero before means "now" — the // 时间戳返回。before 为零意味着「现在」—— 调用方「把我看到的全部标为已读」
// caller's "mark everything I have seen" case, which does not have to read the // 的场景,无需自己读时钟。
// clock itself.
// //
// The cursor only ever moves forward: the UPDATE applies SQLite's two-argument // 游标只向前移动:该 UPDATE 使用 SQLite 的双参数标量 MAX,因此一个过期或
// scalar MAX, so a stale or out-of-order request (two ends racing, a retried // 乱序的请求(两端竞争、请求重试)绝不能让消息重新变为未读。负的 before
// request) can never un-read messages. A negative before therefore leaves the stored // 因此不会挪动已存的值,而不是把它向后移。
// value untouched rather than moving it backwards.
// //
// An unknown session id returns ErrSessionNotFound, matching RenameSession/SetPinned // 未知的会话 id 返回 ErrSessionNotFound,与 RenameSession/SetPinned 一致
// — isolated (group/stranger) sessions have no chat_sessions row and are not // —— 隔离(群聊/陌生人)会话没有 chat_sessions 行,在此不可寻址。
// addressable here.
func (s *Store) MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error) { func (s *Store) MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error) {
ms := before.UnixMilli() ms := before.UnixMilli()
if before.IsZero() { if before.IsZero() {
+46 -56
View File
@@ -13,15 +13,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/db"
) )
// These tests cover the D4 per-session read cursor (design/16 §4.2, Android // 这些测试覆盖 D4 的每会话读游标(design/16 §4.2,Android 协同 B3):
// collaboration B3): the unread count derived in ListSessions, the forward-only // ListSessions 推导出的未读数、MarkSessionRead 只向前推进的游标语义,
// cursor semantics of MarkSessionRead, and the 0010 migration's upgrade backfill. // 以及 0010 迁移的升级回填。
// TestListSessionsUnreadCountFromCursor pins the unread derivation: a message counts as // TestListSessionsUnreadCountFromCursor 锁定未读推导:一条消息只有在严格新于
// unread only when it is strictly newer than the session's cursor (created_at > read_at), // 会话游标(created_at > read_at)时才计为未读,因此游标包含它所命名的那个
// so the cursor is inclusive of the timestamp it names (a client that has rendered a // 时间戳(渲染过 T 时刻消息的客户端把 T 标为已读)。全新会话从游标 0 开始、
// message sent at T marks T read). A fresh session starts at cursor 0 with everything // 一切皆未读,而游标落在最新消息处会清空角标。
// unread, and a cursor at the newest message clears the badge.
func TestListSessionsUnreadCountFromCursor(t *testing.T) { func TestListSessionsUnreadCountFromCursor(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -31,11 +30,11 @@ func TestListSessionsUnreadCountFromCursor(t *testing.T) {
t.Fatalf("seed %d: %v", ts, err) t.Fatalf("seed %d: %v", ts, err)
} }
} }
// Never read: all three messages are unread (a brand-new account/session). // 从未读过:三条消息全未读(全新账号/会话)。
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 { if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 {
t.Fatalf("initial unreadCount = %d, want 3", got) t.Fatalf("initial unreadCount = %d, want 3", got)
} }
// Cursor on a message's own timestamp counts that message as read. // 游标压在消息自身的毫秒上就算该消息已读。
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)) readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000))
if err != nil { if err != nil {
t.Fatalf("mark read: %v", err) t.Fatalf("mark read: %v", err)
@@ -46,14 +45,14 @@ func TestListSessionsUnreadCountFromCursor(t *testing.T) {
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 { if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
t.Fatalf("unreadCount after cursor 2000 = %d, want 1 (only 3000 newer)", got) t.Fatalf("unreadCount after cursor 2000 = %d, want 1 (only 3000 newer)", got)
} }
// A cursor between two messages reads the earlier one only. // 游标落在两条消息之间只读到较早那条。
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2500)); err != nil { if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2500)); err != nil {
t.Fatalf("mark read 2500: %v", err) t.Fatalf("mark read 2500: %v", err)
} }
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 { if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
t.Fatalf("unreadCount after cursor 2500 = %d, want 1", got) t.Fatalf("unreadCount after cursor 2500 = %d, want 1", got)
} }
// A cursor at the newest message clears the badge. // 游标落在最新消息处清空角标。
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil { if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil {
t.Fatalf("mark read 3000: %v", err) t.Fatalf("mark read 3000: %v", err)
} }
@@ -62,9 +61,8 @@ func TestListSessionsUnreadCountFromCursor(t *testing.T) {
} }
} }
// TestListSessionsUnreadCountSessionIsolation pins the per-session scope: each session // TestListSessionsUnreadCountSessionIsolation 锁定每会话作用域:每个会话携带
// carries its own cursor, so reading the main session must not clear (or inflate) a sub // 自己的游标,因此读取主会话不得清空(或撑大)子会话的角标,反之亦然。
// session's badge, and vice versa.
func TestListSessionsUnreadCountSessionIsolation(t *testing.T) { func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -89,7 +87,7 @@ func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
t.Fatalf("sub unreadCount = %d, want 1", got) t.Fatalf("sub unreadCount = %d, want 1", got)
} }
// Reading the main session must leave the sub untouched. // 读取主会话不得动子会话。
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil { if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil {
t.Fatalf("mark main read: %v", err) t.Fatalf("mark main read: %v", err)
} }
@@ -100,7 +98,7 @@ func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
t.Fatalf("sub unreadCount after main read = %d, want 1 (cursors are per-session)", got) t.Fatalf("sub unreadCount after main read = %d, want 1 (cursors are per-session)", got)
} }
// A message arriving after the read re-opens the main badge. // 读取之后到达的消息会重新点亮主会话角标。
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil { if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil {
t.Fatalf("append main-3: %v", err) t.Fatalf("append main-3: %v", err)
} }
@@ -109,9 +107,8 @@ func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
} }
} }
// TestMarkSessionReadForwardOnly covers the racing/malformed-request side of the cursor // TestMarkSessionReadForwardOnly 覆盖游标契约中竞争/畸形请求的一面:游标绝不后
// contract: the cursor never moves backwards, so a stale or repeated request is // 退,因此过期或重复的请求是幂等的,绝不能让已读消息复活。
// idempotent and can never resurrect already-read messages.
func TestMarkSessionReadForwardOnly(t *testing.T) { func TestMarkSessionReadForwardOnly(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -121,13 +118,12 @@ func TestMarkSessionReadForwardOnly(t *testing.T) {
t.Fatalf("seed %d: %v", ts, err) t.Fatalf("seed %d: %v", ts, err)
} }
} }
// Advance to the newest message. // 推进到最新消息。
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)) readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000))
if err != nil || readAt != 3000 { if err != nil || readAt != 3000 {
t.Fatalf("mark 3000 = (%d, %v), want (3000, nil)", readAt, err) t.Fatalf("mark 3000 = (%d, %v), want (3000, nil)", readAt, err)
} }
// A stale request (an end that was behind, or a retry of an older read) must not // 过期请求(落后的端,或一次更旧读取的重试)不得把游标后退并重新点亮角标。
// move the cursor back and re-open the badge.
readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(1000)) readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(1000))
if err != nil { if err != nil {
t.Fatalf("stale mark: %v", err) t.Fatalf("stale mark: %v", err)
@@ -138,8 +134,7 @@ func TestMarkSessionReadForwardOnly(t *testing.T) {
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 { if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
t.Fatalf("unreadCount after stale mark = %d, want 0 (never un-read)", got) t.Fatalf("unreadCount after stale mark = %d, want 0 (never un-read)", got)
} }
// Repeating the same cursor is a no-op, and a negative cursor is clamped by the // 重复同一游标是空操作,负游标被同一个 MAX 钳制(它根本无法挪动已存的值)。
// same MAX (it cannot move the stored value at all).
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil || readAt != 3000 { if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil || readAt != 3000 {
t.Fatalf("repeat mark = (%d, %v), want (3000, nil)", readAt, err) t.Fatalf("repeat mark = (%d, %v), want (3000, nil)", readAt, err)
} }
@@ -148,9 +143,9 @@ func TestMarkSessionReadForwardOnly(t *testing.T) {
} }
} }
// TestMarkSessionReadZeroTimeMeansNow covers the "mark everything I have seen" call the // TestMarkSessionReadZeroTimeMeansNow 覆盖 HTTP 层在请求体省略 before 时发出的
// HTTP layer makes when the body omits before: the zero time resolves to the wall clock, // 「把我看到的全部标为已读」调用:零时刻解析为墙上时钟,从而读到所有已存在的
// which reads every message that already exists. // 消息。
func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) { func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -174,10 +169,9 @@ func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) {
} }
} }
// TestMarkSessionReadUnknownSession pins the error contract: an id with no chat_sessions // TestMarkSessionReadUnknownSession 锁定错误契约:没有 chat_sessions 行的 id
// row (including an isolated group/stranger session) is ErrSessionNotFound, so the HTTP // (包括隔离的群聊/陌生人会话)是 ErrSessionNotFound,因此 HTTP 层可以回答 404,
// layer can answer 404 instead of silently reporting success for a session that does not // 而不是为一个不存在的会话悄悄报告成功。
// exist.
func TestMarkSessionReadUnknownSession(t *testing.T) { func TestMarkSessionReadUnknownSession(t *testing.T) {
t.Parallel() t.Parallel()
st := newStore(t) st := newStore(t)
@@ -186,8 +180,8 @@ func TestMarkSessionReadUnknownSession(t *testing.T) {
} }
} }
// migrationFileSQL reads one migration file from the source tree, so the test applies // migrationFileSQL 从源码树读取一个迁移文件,使测试应用的是嵌入式迁移执行的那份
// the same SQL text the embedded migration runs rather than a copy. // SQL 文本,而不是一份副本。
func migrationFileSQL(t *testing.T, name string) string { func migrationFileSQL(t *testing.T, name string) string {
t.Helper() t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name)) b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name))
@@ -197,7 +191,7 @@ func migrationFileSQL(t *testing.T, name string) string {
return string(b) return string(b)
} }
// columnPresent reports whether table has a column named column. // columnPresent 报告 table 是否有名为 column 的列。
func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool { func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool {
t.Helper() t.Helper()
var n int var n int
@@ -208,10 +202,9 @@ func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool {
return n == 1 return n == 1
} }
// openLegacyDB opens an in-memory DB carrying the pre-0010 schema (chat_sessions with no // openLegacyDB 打开一个携带 0010 之前 schema(chat_sessions 没有 read_at 列)的
// read_at column) plus two legacy sessions with prior history — the real fixed main // 内存库,放入两个带既有历史的遗留会话 —— 真实固定主会话 "web" 与一个子会话
// session "web" and a sub session — so the 0010 migration can be applied to a database // —— 使 0010 迁移能应用到一个真正早于此特性的数据库上。
// that really predates this feature.
func openLegacyDB(t *testing.T) *sql.DB { func openLegacyDB(t *testing.T) *sql.DB {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -273,12 +266,11 @@ func openLegacyDB(t *testing.T) *sql.DB {
return conn return conn
} }
// TestMigration0010ReadAtBackfill exercises the real 0010 migration against a legacy // TestMigration0010ReadAtBackfill 拿真实的 0010 迁移跑在一个遗留库上:up 增加该
// database: up adds the column and backfills each session's cursor to its newest message, // 列并把每个会话的游标回填到其最新消息,使升级日对既有历史报告 0 未读 —— 包括
// so upgrade day reports 0 unread for existing history — including the fixed main session, // 固定主会话,它的既有历史正是这次升级的全部意义 —— 而存储(它幂等地重发规范
// whose existing history is the whole point of the upgrade — and the store (which // DDL)随后能在其上工作;迁移之后到达的消息在标记已读前都是未读;down 再次
// re-issues the canonical DDL idempotently) then works on top of it; a message that arrives // 丢弃该列。
// after the migration is unread until it is marked read; down drops the column again.
func TestMigration0010ReadAtBackfill(t *testing.T) { func TestMigration0010ReadAtBackfill(t *testing.T) {
ctx := context.Background() ctx := context.Background()
conn := openLegacyDB(t) conn := openLegacyDB(t)
@@ -289,18 +281,17 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.up.sql")); err != nil { if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
t.Fatalf("apply 0010 up: %v", err) t.Fatalf("apply 0010 up: %v", err)
} }
// Bring the simulated legacy database up to the current head (0011 adds the // 让模拟的遗留库追到当前 head(0011 增加会话/消息 persona 列),因为存储
// session/message persona columns), because the store reads the canonical schema: // 读取的是规范 schema:落后一个迁移的库会缺 persona_id。生产中的升级顺序是
// a database left one migration behind would lack persona_id. Upgrade ordering in // db.MigrateUp,因此这复现了真实升级的结果。
// production is db.MigrateUp, so this mirrors what a real upgrade produces.
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0011_session_persona.up.sql")); err != nil { if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0011_session_persona.up.sql")); err != nil {
t.Fatalf("apply 0011 up: %v", err) t.Fatalf("apply 0011 up: %v", err)
} }
if !columnPresent(t, conn, "chat_sessions", "read_at") { if !columnPresent(t, conn, "chat_sessions", "read_at") {
t.Fatal("read_at should exist after the 0010 up migration") t.Fatal("read_at should exist after the 0010 up migration")
} }
// The backfill is each session's newest message timestamp, not a blanket 0 (which // 回填的是每个会话最新消息的时间戳,不是一刀切的 0(那会把既有历史全部标为
// would mark the whole existing history unread) and not the first message's. // 未读),也不是第一条消息的时间戳。
for _, tc := range []struct { for _, tc := range []struct {
sessionID string sessionID string
want int64 want int64
@@ -318,10 +309,9 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
} }
} }
// The store comes up on the migrated schema and reports no unread for either // 存储建立在迁移后的 schema 上,对两个既有会话都报告无未读(升级日不得点亮
// pre-existing session (upgrade day must not light up every row, main session // 每一行,主会话包括在内);存储为主会话做的 INSERT OR IGNORE 不得覆盖回填
// included); the store's INSERT OR IGNORE for the main session must not overwrite // 的游标。
// the backfilled cursor.
st, err := NewStore(ctx, conn, testLogger()) st, err := NewStore(ctx, conn, testLogger())
if err != nil { if err != nil {
t.Fatalf("new store on migrated db: %v", err) t.Fatalf("new store on migrated db: %v", err)
@@ -331,7 +321,7 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
t.Fatalf("legacy session %s unreadCount = %d, want 0 after backfill", sessionID, got) t.Fatalf("legacy session %s unreadCount = %d, want 0 after backfill", sessionID, got)
} }
} }
// A message written after the migration is unread, and marking it read clears it. // 迁移之后写入的消息是未读的,标记已读即清除。
if err := st.Append(ctx, Message{SessionID: "web:legacy", Role: RoleAssistant, Content: "new", CreatedAt: 9000}); err != nil { if err := st.Append(ctx, Message{SessionID: "web:legacy", Role: RoleAssistant, Content: "new", CreatedAt: 9000}); err != nil {
t.Fatalf("append after migration: %v", err) t.Fatalf("append after migration: %v", err)
} }
+172 -206
View File
@@ -1,16 +1,15 @@
// Package history implements the persistent chat-history session model described in // Package history 实现 DevDocs/design/16-会话持久化与主次级会话.md 描述的持久化
// DevDocs/design/16-会话持久化与主次级会话.md. // 聊天历史会话模型。
// //
// It is the storage layer, not the orchestration/context layer: it persists the raw // 它是存储层,而不是编排/上下文层:它持久化原始消息流(chat_messages)与持久化
// message stream (chat_messages) and the persistent session records (chat_sessions) // 会话记录(chat_sessions),使 Web 前端能请求并展示历史,子会话也能被创建、列出
// so the Web frontend can request + display history, and secondary (sub) sessions can // 与删除。它刻意不碰记忆(design/16 §3.2:记忆是角色作用域的,住在
// be created, listed and deleted. It intentionally does not touch memory (design/16 // runtime/memory)、不构建上下文(runtime/loop),也不调度回合
// §3.2: memory is persona-scoped and lives in runtime/memory), does not build context // (gateway/session_orchestrator)。
// (runtime/loop) and does not schedule turns (gateway/session_orchestrator).
// //
// Session model (design/16 §2.1): there is exactly one fixed main session (id "web") and // 会话模型(design/16 §2.1):有且只有一个固定主会话(id "web")与任意数量的子
// any number of sub sessions (id "web:<uuid>"). Main + sub share the same persona and // 会话(id "web:<uuid>")。主会话与子会话共享同一角色,因而共享同一记忆;本包
// therefore the same memory; this package only models sessions + messages. // 只建模会话与消息。
package history package history
import ( import (
@@ -27,50 +26,50 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "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 ( const (
// RoleUser, RoleAssistant, RoleSystem classify a message's author. // RoleUser、RoleAssistant、RoleSystem 归类一条消息的作者。
RoleUser = "user" RoleUser = "user"
RoleAssistant = "assistant" RoleAssistant = "assistant"
RoleSystem = "system" RoleSystem = "system"
// SessionMain and SessionSub classify a persistent session. // SessionMain 与 SessionSub 归类一个持久化会话。
SessionMain = "main" SessionMain = "main"
SessionSub = "sub" SessionSub = "sub"
// PlatformWeb is the source-platform tag for messages that originate on the Web // PlatformWeb 是源自 Web 主会话的消息所用的来源平台标签
// main session (design/16 §4.4). It is both the column default and the value // (design/16 §4.4)。它既是该列的默认值,也是调用方未设置
// assigned when a caller leaves Message.Platform empty. // Message.Platform 时赋的值。
PlatformWeb = "web" PlatformWeb = "web"
) )
// MainSessionID is the fixed id of the one main session (design/16 §2.1). Sub session // MainSessionID 是那个唯一主会话的固定 id(design/16 §2.1)。子会话 id 为
// ids are MainSessionID + ":" + uuid. // MainSessionID + ":" + uuid。
const MainSessionID = "web" const MainSessionID = "web"
// defaultPageLimit is the page size used when the caller does not pass one. // defaultPageLimit 是调用方未传限制时使用的页大小。
const defaultPageLimit = 50 const defaultPageLimit = 50
// maxPageLimit bounds a client-supplied limit so a single request cannot drain memory. // maxPageLimit 为客户端传入的 limit 设上界,使单个请求无法耗尽内存。
const maxPageLimit = 200 const maxPageLimit = 200
// Sentinels distinguishing history package failures. Callers wrap them with %w so // 区分 history 包各类失败的哨兵错误。调用方用 %w 包装它们,使 errors.Is 能穿透
// errors.Is works through the chain. // 错误链。
var ( var (
// ErrNilDB is returned when NewStore receives a nil *sql.DB. // ErrNilDB 在 NewStore 收到 nil 的 *sql.DB 时返回。
ErrNilDB = errors.New("history: nil 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") ErrImmutableMain = errors.New("history: the main session cannot be deleted")
// ErrMessageNotFound is returned by UpdateMessage/DeleteMessage when no // ErrMessageNotFound 在没有 chat_messages 行携带所请求 id 时由
// chat_messages row carries the requested id. // UpdateMessage/DeleteMessage 返回。
ErrMessageNotFound = errors.New("history: message not found") ErrMessageNotFound = errors.New("history: message not found")
) )
// Message is one persisted chat message. CreatedAt is a Unix millisecond timestamp and // Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任
// doubles as the pagination cursor (design/16 §3.1): pages are ordered newest-first and // 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条
// "before" is the created_at of the oldest message on the previous page. // 消息的 created_at。
type Message struct { type Message struct {
ID string ID string
SessionID string SessionID string
@@ -80,174 +79,151 @@ type Message struct {
Mood string Mood string
Image string Image string
Thinking string Thinking string
// Platform tags the source platform of a message (design/16 §4.4). The Web main // Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台
// session is shared across platforms: messages that originate on the Web carry // 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台
// "web", while IM-owner-private messages carry the channel platform (e.g. // (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认
// "telegram", "qq-obv11", "weixin-ilink"). An empty Platform defaults to "web" so // 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。
// legacy/explicitly-Web writes stay on the Web lane without a separate assignment.
Platform string Platform string
// DeviceID is a short, client-produced identifier for the concrete device/app // DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出
// that sent the message (design/16 §4.5) — e.g. "web:chrome:a1b2c3" for a // (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的
// browser, "weixin-ilink:conf0:wx_uid_123" for IM. It complements Platform: two // "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个
// clients on the same platform share Platform but differ in DeviceID. // 客户端共享 Platform,但 DeviceID 不同。
DeviceID string DeviceID string
// SenderName is the human-readable display name of the message's sender // SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方
// (design/16 §4.6). It is set for third-party messages (group chats and // 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者
// 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 string SenderName string
// AdapterID is the adapter (platform + account) registration name of the IM // AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如
// source that produced the message, e.g. "kook:3bef..." for a multi-account // 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸
// channels.json registration or a bare platform name (e.g. "kook") when the // 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空,
// account comes from env. IM adapter-originated messages carry it; the Web main // 因此对 Web 消息它恒为空。
// session leaves it empty, so it is always '' for Web messages.
AdapterID string AdapterID string
// PersonaID is the role that SPOKE this message (design/22 §7-5). A session // PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留
// may switch roles while keeping its context, so the authoring role is recorded // 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨
// per message and the context/UI can tell "who said the Nth turn". Empty means // 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色
// "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. // 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定
// It is display/attribution metadata only: identity and permissions are decided // (项目红线)。
// by the channel actor metadata (project red line).
PersonaID string PersonaID string
CreatedAt int64 CreatedAt int64
} }
// SessionMeta is the wire-facing metadata of one persistent session. // SessionMeta 是一个持久化会话面向线上的元数据。
type SessionMeta struct { type SessionMeta struct {
ID string ID string
Type string // "main" | "sub" Type string // "main" | "sub"(主会话 | 子会话)
Title string Title string
CreatedAt int64 CreatedAt int64
// Pinned floats the session to the top of ListSessions. It is display/order // Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限
// metadata only; identity and permissions never derive from it. // 绝不由它推导。
Pinned bool Pinned bool
// LastMessage is a display-only preview of the session's newest message, filled // LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中
// by ListSessions in the same query. It is nil for a session with no messages. // 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求;
// A client uses it to render the conversation row without a per-session request; // 身份与权限绝不由它推导。
// identity and permissions never derive from it.
LastMessage *MessageSummary LastMessage *MessageSummary
// UnreadCount is the number of messages newer than the session's read cursor // UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由
// (design/16 §4.2, D4), computed by ListSessions in the same query. The cursor // ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端
// is session-scoped and single-owner: reading on any one end marks the session // 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是
// read for every end of the account. Like LastMessage it is display-only — // 展示用 —— 身份与权限绝不由它推导。
// identity and permissions never derive from it.
UnreadCount int UnreadCount int
// PersonaID is the role this session belongs to (design/22 §2/§4.1). It is the // PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由
// 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 // (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定
// every pre-existing session and any session created without an explicit role // 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随
// keeps the pre-multi-character behavior (design/22 §4.7) and still follows a // 默认角色变更(design/22 §7-6)。
// default-role change (design/22 §7-6).
PersonaID string PersonaID string
} }
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can // HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有
// hold an interface rather than a concrete *Store. It is deliberately kept to the calls // 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际
// the runtime-level consumers (channelbridge, backthink) actually make: the D4 read // 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里
// cursor (MarkSessionRead) is declared on the consumer-side interface in // (internal/services/gateway 的 contracts.go),这样别处更窄的存储替身不必
// internal/services/gateway (contracts.go) instead, so a narrower store fake elsewhere // 实现一个它从不推进的游标。*Store 同时满足两者。
// does not have to implement a cursor it never advances. *Store satisfies both.
type HistoryStore interface { 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 Append(ctx context.Context, msg Message) error
// AppendFull persists msg like Append but returns the complete stored message // AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息(含分配的
// (with the assigned id/timestamp) so a caller can broadcast it for cross-end sync. // id/时间戳),使调用方能广播它做跨端同步。
AppendFull(ctx context.Context, msg Message) (Message, error) AppendFull(ctx context.Context, msg Message) (Message, error)
// List returns up to limit messages for sessionID, newest first. before is a // List 按最新优先返回 sessionID 的至多 limit 条消息。before 是毫秒时间戳
// millisecond timestamp cursor: when nonzero, only messages older than before are // 游标:非零时只返回早于 before 的消息(不含边界,与前端 "before" 语义
// returned (inclusive-free, matching the "before" bound on the frontend). platform // 一致)。platform 是可选的来源平台过滤(design/16 §4.4):非空时只返回
// is an optional source-platform filter (design/16 §4.4): when non-empty only // 携带该平台的消息;为空时返回全部平台(Web 客户端请求完整主会话,IM
// 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(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) 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 // ListAfter 按最旧优先返回 sessionID 中严格新于 after(created_at >
// (created_at > after) in oldest-first order, so a client that reconnects can pull // after)的至多 limit 条消息,使重连的客户端能从自己已见过的最新
// the messages it missed forward from the newest createdAt it has already seen. // createdAt 向前拉取错过的消息。platform 的过滤同 List。limit 钳制到
// platform filters as in List. limit is clamped to [1, maxPageLimit]. // [1, maxPageLimit]。
ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error) 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, // Recent 按最旧优先返回 sessionID 最近的 n 条消息,供 WS 回放 / 上下文
// for WS replay / context tail usage. // 尾部使用。
Recent(ctx context.Context, sessionID string, n int) ([]Message, error) Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
// ListPlatformLog returns up to limit messages for an IM platform across every // ListPlatformLog 按最新优先返回某个 IM 平台跨所有非主(隔离)会话的至多
// non-main (isolated) session, newest first, for the adapter log page (design/16 // limit 条消息,供适配器日志页使用(design/16 §4.6 §4)。它排除固定主 Web
// §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 // (群聊 + 陌生人私聊)。before 是毫秒游标;0 表示最新一页。
// owner-private mirror. before is a millisecond cursor; 0 means newest page.
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
// ListAdapterLog returns up to limit messages for a single IM adapter (platform + // ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// account registration name, e.g. "kook:3bef..."), newest first, for the // "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。与
// per-adapter log page. Unlike ListPlatformLog it does NOT exclude the main web // ListPlatformLog 不同,它不排除主 Web 会话:主人的 IM 私聊住在主会话里
// session: an owner-private IM chat lives in the main session but still carries // 但仍携带 adapter_id,因此按适配器的日志包含主人镜像。before 是毫秒
// the adapter_id, so a per-adapter log includes the owner mirror. before is a // 游标;0 表示最新一页。adapterID 为空时返回空切片。
// millisecond cursor; 0 means the newest page. An empty adapterID returns an
// empty slice.
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error)
// ListAllLog returns up to limit messages across every IM adapter source // ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多
// (adapter_id != ''), newest first, for the adapter overview page. before is a // limit 条消息,供适配器总览页使用。before 是毫秒游标;0 表示最新一页。
// millisecond cursor; 0 means the newest page.
ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error)
// DeleteSession removes a sub session and all its messages. The main session is // DeleteSession 删除一个子会话及其全部消息。主会话不可变,返回
// immutable and returns ErrImmutableMain. // ErrImmutableMain。
DeleteSession(ctx context.Context, sessionID string) error DeleteSession(ctx context.Context, sessionID string) error
// Search returns up to limit messages for sessionID whose content contains query // Search 按最新优先返回 sessionID 中内容包含 query 的至多 limit 条消息
// (case-insensitive ASCII LIKE, with %/_/\ escaped), newest first. before is the // (ASCII 大小写不敏感的 LIKE,%/_/\ 已转义)。before 与 List 是同一个
// same millisecond cursor as List; limit is clamped to [1, maxPageLimit]. An empty // 毫秒游标;limit 钳制到 [1, maxPageLimit]。空 query 返回空切片(调用方
// query returns an empty slice (the caller falls back to List for plain paging). // 退回 List 做普通翻页)。Search 始终限定在单个会话内,因此隔离的
// Search is always scoped to one session, so an isolated group/stranger session can // 群聊/陌生人会话绝不浮现在另一个会话的结果中。
// never surface in another session's results.
Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error) 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 // UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull
// updated message (like AppendFull, so the caller can broadcast it without a // 一样,使调用方能无需二次读取就广播它)。只有 content 变化;role、时间
// second read). Only content changes; role/timestamps/platform stay as stored. // 戳、platform 保持已存值。未知 id 返回 ErrMessageNotFound。
// An unknown id returns ErrMessageNotFound.
UpdateMessage(ctx context.Context, id, content string) (Message, error) UpdateMessage(ctx context.Context, id, content string) (Message, error)
// DeleteMessage removes a single message. An unknown id returns // DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。
// ErrMessageNotFound.
DeleteMessage(ctx context.Context, id string) error DeleteMessage(ctx context.Context, id string) error
// RenameSession sets a session's display title. Both the main session and sub // RenameSession 设置会话的显示标题。主会话与子会话都可重命名(标题只是
// sessions may be renamed (a title is display-only metadata); an unknown id // 展示元数据);未知 id 返回 ErrSessionNotFound。
// returns ErrSessionNotFound.
RenameSession(ctx context.Context, sessionID, title string) error RenameSession(ctx context.Context, sessionID, title string) error
// SetPinned pins or unpins a session. Pinning is display/order metadata only; // SetPinned 置顶或取消置顶一个会话。置顶只是展示/排序元数据;主会话与
// both the main session and sub sessions may be pinned. An unknown id returns // 子会话都可置顶。未知 id 返回 ErrSessionNotFound。
// ErrSessionNotFound.
SetPinned(ctx context.Context, sessionID string, pinned bool) error SetPinned(ctx context.Context, sessionID string, pinned bool) error
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid). // NewSubSession 创建一个子会话并返回其 id(MainSessionID:uuid)。它不绑定
// It binds no explicit role, so the new session inherits the global default // 显式角色,因此新会话继承全局默认(persona_id 为空);*Store 另提供
// (persona_id = ''); *Store additionally offers NewSubSessionFor for the // NewSubSessionFor 这个绑定角色的变体(design/22 §4.1),本窄接口刻意
// role-bound variant (design/22 §4.1), which this narrow consumer-side interface // 省略它,因为 channelbridge 与 backthink 都不创建会话。
// deliberately omits because neither channelbridge nor backthink ever creates a
// session.
NewSubSession(ctx context.Context, ownerID string) (string, error) NewSubSession(ctx context.Context, ownerID string) (string, error)
// ListSessions returns pinned sessions first, then the main session, then every // ListSessions 先返回置顶会话,再返回主会话,最后返回每个子会话(最旧
// sub session oldest first. // 优先)。
ListSessions(ctx context.Context) ([]SessionMeta, error) ListSessions(ctx context.Context) ([]SessionMeta, error)
} }
// Store is the SQLite-backed chat-history store. It owns the chat_sessions and // Store 是基于 SQLite 的聊天历史存储。它拥有 chat_sessions 与 chat_messages
// chat_messages tables. Concurrency is delegated to *sql.DB (safe for concurrent use), // 两张表。并发交给 *sql.DB(可安全并发使用),因此 Store 不再持有额外的锁。
// so Store holds no further lock. Every query is parameterized and reads list columns // 每条查询都参数化,读取时显式列出列(不用 SELECT *);读取都接受 context。
// explicitly (no SELECT *); reads take a context.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
} }
// rowScanner is satisfied by both *sql.Row and *sql.Rows, so a single scanMessage // rowScanner 由 *sql.Row 与 *sql.Rows 同时满足,因此单个 scanMessage 辅助函数
// helper serves List and Recent. // 就能服务 List 与 Recent。
type rowScanner interface { type rowScanner interface {
Scan(dest ...any) error Scan(dest ...any) error
} }
// NewStore builds a Store on db, ensuring the chat_sessions/chat_messages schema exists // NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在
// and that the fixed main session row is present. The DDL mirrors the 0003 migration; // 且固定主会话行在位。该 DDL 与 0003 迁移一致;它是幂等的,因此存储自足、可从
// it is idempotent so the store is self-contained and usable from any *sql.DB, matching // 任意 *sql.DB 使用,与 memory 和 session 包一致。db 必须可用;nil 返回
// the memory and session packages. db must be live; nil returns ErrNilDB. log may be nil. // ErrNilDB。log 可以为 nil。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil { if db == nil {
return nil, ErrNilDB return nil, ErrNilDB
@@ -269,24 +245,23 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
return s, nil return s, nil
} }
// Append persists msg, assigning a fresh id and a now-ms CreatedAt when the caller left // Append 持久化 msg,在调用方把 id/CreatedAt 留空/留零时分配新的 id 与现在的
// either empty/zero. It returns the error wrapped with the operation name. Callers that // 毫秒时间戳。它返回包装了操作名的错误。需要拿回生成的 id/时间戳的调用方应
// need the generated id/timestamp back should use AppendFull instead. // 改用 AppendFull。
func (s *Store) Append(ctx context.Context, msg Message) error { func (s *Store) Append(ctx context.Context, msg Message) error {
_, err := s.insert(ctx, msg) _, err := s.insert(ctx, msg)
return err return err
} }
// AppendFull persists msg like Append but returns the complete stored message — the id // AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息 —— 即调用方留空/
// and CreatedAt the store assigned when the caller left them empty/zero — so a caller can // 留零时存储分配的 id 与 CreatedAt —— 使调用方能广播它做跨端实时同步
// broadcast it for cross-end realtime sync (design/16 §4.2) without a second read. // (design/16 §4.2),无需二次读取。
func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) { func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) {
return s.insert(ctx, msg) return s.insert(ctx, msg)
} }
// insert is the shared Append/AppendFull body. It assigns a fresh id and a now-ms // insert 是 Append/AppendFull 共用的主体。它在调用方把 id/CreatedAt 留空/留零时
// CreatedAt when the caller left either empty/zero, then inserts the row and returns the // 分配新的 id 与现在的毫秒时间戳,然后插入该行并返回得到的消息。
// resulting message.
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) { func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.ID == "" { if msg.ID == "" {
id, err := newID() id, err := newID()
@@ -315,13 +290,11 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
return msg, nil return msg, nil
} }
// List returns up to limit messages for sessionID, newest first. When before is nonzero // List 按最新优先返回 sessionID 的至多 limit 条消息。before 非零时把查询限制为
// it bounds the query to messages strictly older than before, so a client can page upward // 严格早于 before 的消息,使客户端可以传入上一页最旧那条消息的 created_at 向上
// by passing the oldest message's created_at from the previous page. When platform is // 翻页。platform 非空时把该页进一步限制为携带该来源平台的消息(design/16 §4.4:
// non-empty it further restricts the page to messages carrying that source platform // Web 客户端省略 platform 以拿到完整主会话,IM 适配器传入自己的平台只看自己的
// (design/16 §4.4: the Web client omits platform for the full main session, an IM // 通道)。返回切片非 nil。limit 钳制到 [1, maxPageLimit]。
// adapter passes its own platform to see only its lane). The returned slice is non-nil.
// limit is clamped to [1, maxPageLimit].
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) { func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit) 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 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 return msgs, nil
} }
// ListPlatformLog returns up to limit messages for platform across every non-main // ListPlatformLog 按最新优先返回 platform 跨所有非主(隔离)会话的至多 limit
// (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 // 浮现到适配器日志页(design/16 §4.6 §4)。before 是毫秒游标;非零时只返回
// adapter log page (design/16 §4.6 §4). before is a millisecond cursor; when nonzero // 严格更旧的消息。返回切片非 nil。
// only strictly older messages are returned. The returned slice is non-nil.
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) { func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit) 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 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 return msgs, nil
} }
// ListAdapterLog returns up to limit messages for a single IM adapter (platform + // ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// account registration name, e.g. "kook:3bef..."), newest first, for the per-adapter // "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。它不排除主 Web
// log page. It does NOT exclude the main web session: an owner-private IM chat lives // 会话:主人的 IM 私聊住在主会话里但仍携带 adapter_id,因此按适配器的日志包含
// in the main session but still carries the adapter_id, so a per-adapter log includes // 主人镜像。adapterID 为空时返回空(非 nil)切片。before 是毫秒游标;非零时只
// the owner mirror. An empty adapterID returns an empty (non-nil) slice. before is a // 返回严格更旧的消息。返回切片非 nil。
// millisecond cursor; when nonzero only strictly older messages are returned. The
// returned slice is non-nil.
func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) { func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit) limit = clampLimit(limit)
if adapterID == "" { if adapterID == "" {
@@ -437,10 +407,9 @@ func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int
return msgs, nil return msgs, nil
} }
// ListAllLog returns up to limit messages across every IM adapter source // ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多 limit
// (adapter_id != ''), newest first, for the adapter overview page. before is a // 条消息,供适配器总览页使用。before 是毫秒游标;非零时只返回严格更旧的消息。
// millisecond cursor; when nonzero only strictly older messages are returned. The // 返回切片非 nil。
// returned slice is non-nil.
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) { func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit) 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 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 return msgs, nil
} }
// Recent returns the most recent n messages for sessionID in oldest-first order (the // Recent 按最旧优先返回 sessionID 最近的 n 条消息(最靠尾部的那段窗口),供
// tail-most window), for WS replay / context tail. The returned slice is non-nil. // WS 回放 / 上下文尾部使用。返回切片非 nil。
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) { func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
if n < 1 { if n < 1 {
n = defaultPageLimit n = defaultPageLimit
@@ -486,8 +455,7 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
// Recent returns newest-last so the window is in chronological order; build it and // Recent 让最新的排在最后,使窗口处于时间顺序;先按查询顺序构建再原地反转。
// reverse in place.
msgs := make([]Message, 0) msgs := make([]Message, 0)
for rows.Next() { for rows.Next() {
m, err := scanMessage(rows) m, err := scanMessage(rows)
@@ -505,8 +473,8 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
return msgs, nil return msgs, nil
} }
// DeleteSession removes a sub session and all its messages in one transaction. The fixed // DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
// main session is immutable and returns ErrImmutableMain (design/16 §6). // 返回 ErrImmutableMain(design/16 §6)。
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error { func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
if sessionID == MainSessionID { if sessionID == MainSessionID {
return ErrImmutableMain return ErrImmutableMain
@@ -529,17 +497,16 @@ func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
return nil return nil
} }
// NewSubSession creates a sub session with no explicit role and returns its id // NewSubSession 创建一个不绑定显式角色的子会话并返回其 id
// (MainSessionID:uuid). The new session therefore inherits the global default role // (MainSessionID:uuid)。新会话因此继承全局默认角色(persona_id 为空);要在
// (persona_id = ''); use NewSubSessionFor to bind a role at creation time. ownerID // 创建时绑定角色请用 NewSubSessionFor。ownerID 为接口完整性而接受(Web 主人
// is accepted for interface completeness (the Web owner is identified by JWT, not // 由 JWT 识别,不按会话存储);它只以 debug 级别记日志,不被持久化。
// stored per session); it is logged at debug and not persisted.
func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) { func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) {
return s.NewSubSessionFor(ctx, ownerID, "") return s.NewSubSessionFor(ctx, ownerID, "")
} }
// ensureMainSession inserts the fixed main session row when it is absent, so ListSessions // ensureMainSession 在固定主会话行缺失时插入它,使 ListSessions 在任何消息写入
// always reports the main session even before any message is written. // 之前也能报告出主会话。
func (s *Store) ensureMainSession(ctx context.Context) error { func (s *Store) ensureMainSession(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO chat_sessions (id, type, title, created_at) _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO chat_sessions (id, type, title, created_at)
VALUES (?, ?, ?, ?)`, MainSessionID, SessionMain, "", time.Now().UnixMilli()) VALUES (?, ?, ?, ?)`, MainSessionID, SessionMain, "", time.Now().UnixMilli())
@@ -549,7 +516,7 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
return nil return nil
} }
// scanMessage reads one chat_messages row. message columns are scanned positionally. // scanMessage 读取一行 chat_messages。消息各列按位置扫描。
func scanMessage(sc rowScanner) (Message, error) { func scanMessage(sc rowScanner) (Message, error) {
var m Message var m Message
var mode, mood, platform, deviceID, senderName, adapterID string var mode, mood, platform, deviceID, senderName, adapterID string
@@ -560,8 +527,8 @@ func scanMessage(sc rowScanner) (Message, error) {
return m, nil return m, nil
} }
// clampLimit bounds limit to [1, maxPageLimit]; a caller-supplied 0 or negative falls back // clampLimit 把 limit 钳制到 [1, maxPageLimit];调用方传入的 0 或负数回退到
// to defaultPageLimit. // defaultPageLimit。
func clampLimit(limit int) int { func clampLimit(limit int) int {
if limit < 1 { if limit < 1 {
return defaultPageLimit return defaultPageLimit
@@ -572,8 +539,8 @@ func clampLimit(limit int) int {
return limit return limit
} }
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a UUID // newID 返回一个 128 位密码学随机的 id,以十六进制编码。它避免引入 UUID 依赖,
// dependency so the package stays self-contained. // 使本包保持自足。
func newID() (string, error) { func newID() (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
@@ -582,7 +549,7 @@ func newID() (string, error) {
return hex.EncodeToString(b), nil 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 { func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil { if log != nil {
return log return log
@@ -590,9 +557,8 @@ func discardLogger(log *slog.Logger) *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil)) return slog.New(slog.NewTextHandler(io.Discard, nil))
} }
// Schema DDL. The 0003 migration in internal/db is the authoritative mechanism for // Schema DDL。internal/db 里的 0003 迁移是领域表的权威机制,但这里刻意再幂等
// domain tables, but it is intentionally also re-issued here idempotently so the store is // 地发出一次,使存储自足、可从任意 *sql.DB 使用(与 memory 和 session 包一致)。
// self-contained and usable from any *sql.DB (matching the memory and session packages).
const createSessionsTable = ` const createSessionsTable = `
CREATE TABLE IF NOT EXISTS chat_sessions ( CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
+39 -42
View File
@@ -11,12 +11,12 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/db"
) )
// testLogger returns a logger that writes nowhere, keeping test output clean. // testLogger 返回一个不写任何地方的日志器,保持测试输出干净。
func testLogger() *slog.Logger { func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil)) return slog.New(slog.NewTextHandler(io.Discard, nil))
} }
// newStore opens an isolated in-memory DB and returns a Store on it. // newStore 打开一个隔离的内存库并在其上返回 Store。
func newStore(t *testing.T) *Store { func newStore(t *testing.T) *Store {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -58,11 +58,11 @@ func TestAppendAndListNewestFirst(t *testing.T) {
if len(msgs) != 3 { if len(msgs) != 3 {
t.Fatalf("len = %d, want 3", len(msgs)) t.Fatalf("len = %d, want 3", len(msgs))
} }
// Newest first. // 最新优先。
if msgs[0].CreatedAt != 3000 || msgs[1].CreatedAt != 2000 || msgs[2].CreatedAt != 1000 { if msgs[0].CreatedAt != 3000 || msgs[1].CreatedAt != 2000 || msgs[2].CreatedAt != 1000 {
t.Fatalf("order = %+v, want [3000 2000 1000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt, msgs[2].CreatedAt}) t.Fatalf("order = %+v, want [3000 2000 1000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt, msgs[2].CreatedAt})
} }
// Id/timestamp assigned when the caller omits them. // 调用方省略时分配 id/时间戳。
empty := Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"} empty := Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"}
if err := st.Append(ctx, empty); err != nil { if err := st.Append(ctx, empty); err != nil {
t.Fatalf("append no ts: %v", err) t.Fatalf("append no ts: %v", err)
@@ -95,7 +95,7 @@ func TestAppendFullReturnsID(t *testing.T) {
t.Fatalf("AppendFull returned %+v, want the stored message unchanged plus id/created_at", full) t.Fatalf("AppendFull returned %+v, want the stored message unchanged plus id/created_at", full)
} }
// The message is actually persisted under the returned id. // 消息确实以返回的 id 落盘。
msgs, err := st.List(ctx, MainSessionID, 0, 50, "") msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
@@ -104,7 +104,7 @@ func TestAppendFullReturnsID(t *testing.T) {
t.Fatalf("list = %+v, want exactly the persisted message id %q", msgs, full.ID) t.Fatalf("list = %+v, want exactly the persisted message id %q", msgs, full.ID)
} }
// A caller-supplied id/timestamp is preserved, not regenerated. // 调用方传入的 id/时间戳被保留,而不是重新生成。
preset, err := st.AppendFull(ctx, Message{ID: "preset-id", SessionID: MainSessionID, Role: RoleAssistant, Content: "ok", CreatedAt: 42}) preset, err := st.AppendFull(ctx, Message{ID: "preset-id", SessionID: MainSessionID, Role: RoleAssistant, Content: "ok", CreatedAt: 42})
if err != nil { if err != nil {
t.Fatalf("append full preset: %v", err) t.Fatalf("append full preset: %v", err)
@@ -119,8 +119,7 @@ func TestAppendFullListPlatformRoundTrip(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// An IM owner-private message carries its channel platform through the round // 一条 IM 主人私聊消息在 AppendFull → List 往返中携带它的渠道平台。
// trip: AppendFull → List.
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "从微信来", Platform: "weixin-ilink"}) full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "从微信来", Platform: "weixin-ilink"})
if err != nil { if err != nil {
t.Fatalf("append full: %v", err) t.Fatalf("append full: %v", err)
@@ -136,7 +135,7 @@ func TestAppendFullListPlatformRoundTrip(t *testing.T) {
t.Fatalf("list round trip = %+v, want platform weixin-ilink", msgs) t.Fatalf("list round trip = %+v, want platform weixin-ilink", msgs)
} }
// A message without an explicit platform defaults to the web lane. // 未显式指定平台的消息默认为 web 通道。
web, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"}) web, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
if err != nil { if err != nil {
t.Fatalf("append web default: %v", err) t.Fatalf("append web default: %v", err)
@@ -151,8 +150,8 @@ func TestAppendFullListDeviceIDRoundTrip(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// A Web message tagged with a browser deviceID survives the AppendFull → List // 一条带浏览器 deviceID 的 Web 消息活过 AppendFull → List 往返
// round trip (design/16 §4.5). // (design/16 §4.5)。
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "来自浏览器", DeviceID: "web:chrome:a1b2c3"}) full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "来自浏览器", DeviceID: "web:chrome:a1b2c3"})
if err != nil { if err != nil {
t.Fatalf("append full: %v", err) t.Fatalf("append full: %v", err)
@@ -168,7 +167,7 @@ func TestAppendFullListDeviceIDRoundTrip(t *testing.T) {
t.Fatalf("list round trip = %+v, want DeviceID web:chrome:a1b2c3", msgs) t.Fatalf("list round trip = %+v, want DeviceID web:chrome:a1b2c3", msgs)
} }
// Recent (WS replay / context tail) reads the same device_id column. // Recent(WS 回放 / 上下文尾部)读同一个 device_id 列。
recent, err := st.Recent(ctx, MainSessionID, 5) recent, err := st.Recent(ctx, MainSessionID, 5)
if err != nil { if err != nil {
t.Fatalf("recent: %v", err) t.Fatalf("recent: %v", err)
@@ -177,7 +176,7 @@ func TestAppendFullListDeviceIDRoundTrip(t *testing.T) {
t.Fatalf("recent = %+v, want DeviceID web:chrome:a1b2c3", recent) t.Fatalf("recent = %+v, want DeviceID web:chrome:a1b2c3", recent)
} }
// A message without an explicit deviceID persists the column default (''). // 未显式指定 deviceID 的消息持久化列默认值(空)。
om, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"}) om, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
if err != nil { if err != nil {
t.Fatalf("append empty device: %v", err) t.Fatalf("append empty device: %v", err)
@@ -192,7 +191,7 @@ func TestListPlatformFilter(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// Seed across the web lane (explicit + default) and one IM lane. // 跨 web 通道(显式 + 默认)与一条 IM 通道播种。
for _, m := range []Message{ for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "web1", Platform: PlatformWeb, CreatedAt: 1000}, {SessionID: MainSessionID, Role: RoleUser, Content: "web1", Platform: PlatformWeb, CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000}, {SessionID: MainSessionID, Role: RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000},
@@ -203,7 +202,7 @@ func TestListPlatformFilter(t *testing.T) {
} }
} }
// No platform filter → all three (the empty-platform message defaulted to web). // 不带平台过滤 → 三条全出(未指定平台的那条在插入时默认为 web)。
all, err := st.List(ctx, MainSessionID, 0, 50, "") all, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil { if err != nil {
t.Fatalf("list all: %v", err) t.Fatalf("list all: %v", err)
@@ -212,7 +211,7 @@ func TestListPlatformFilter(t *testing.T) {
t.Fatalf("all = %d, want 3", len(all)) t.Fatalf("all = %d, want 3", len(all))
} }
// Filter to the IM lane → only its message. // 过滤到 IM 通道 → 只有它的消息。
tg, err := st.List(ctx, MainSessionID, 0, 50, "telegram") tg, err := st.List(ctx, MainSessionID, 0, 50, "telegram")
if err != nil { if err != nil {
t.Fatalf("list telegram: %v", err) t.Fatalf("list telegram: %v", err)
@@ -221,8 +220,7 @@ func TestListPlatformFilter(t *testing.T) {
t.Fatalf("telegram page = %+v, want [tg]", tg) t.Fatalf("telegram page = %+v, want [tg]", tg)
} }
// Filter to the web lane → web1 + web2 (the one left without an explicit // 过滤到 web 通道 → web1 + web2(那条未显式指定平台的在插入时默认为 web)。
// platform defaulted to web on insert).
web, err := st.List(ctx, MainSessionID, 0, 50, PlatformWeb) web, err := st.List(ctx, MainSessionID, 0, 50, PlatformWeb)
if err != nil { if err != nil {
t.Fatalf("list web: %v", err) t.Fatalf("list web: %v", err)
@@ -231,7 +229,7 @@ func TestListPlatformFilter(t *testing.T) {
t.Fatalf("web page = %d, want 2 (%+v)", len(web), web) t.Fatalf("web page = %d, want 2 (%+v)", len(web), web)
} }
// The filter composes with the before cursor. // 过滤与 before 游标可组合。
older, err := st.List(ctx, MainSessionID, 2000, 50, PlatformWeb) older, err := st.List(ctx, MainSessionID, 2000, 50, PlatformWeb)
if err != nil { if err != nil {
t.Fatalf("list web before: %v", err) t.Fatalf("list web before: %v", err)
@@ -252,7 +250,7 @@ func TestListPaginationBefore(t *testing.T) {
} }
} }
// First page: newest 2. // 第一页:最新 2 条。
page, err := st.List(ctx, MainSessionID, 0, 2, "") page, err := st.List(ctx, MainSessionID, 0, 2, "")
if err != nil { if err != nil {
t.Fatalf("first page: %v", err) t.Fatalf("first page: %v", err)
@@ -260,7 +258,7 @@ func TestListPaginationBefore(t *testing.T) {
if len(page) != 2 || page[0].CreatedAt != 6000 || page[1].CreatedAt != 5000 { if len(page) != 2 || page[0].CreatedAt != 6000 || page[1].CreatedAt != 5000 {
t.Fatalf("first page = %+v, want [6000 5000]", page) t.Fatalf("first page = %+v, want [6000 5000]", page)
} }
// Cursor on the page's oldest (5000) → older page [4000 3000]. // 游标落在该页最旧那条(5000)→ 更旧的一页 [4000 3000]。
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2, "") next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2, "")
if err != nil { if err != nil {
t.Fatalf("next page: %v", err) t.Fatalf("next page: %v", err)
@@ -268,7 +266,7 @@ func TestListPaginationBefore(t *testing.T) {
if len(next) != 2 || next[0].CreatedAt != 4000 || next[1].CreatedAt != 3000 { if len(next) != 2 || next[0].CreatedAt != 4000 || next[1].CreatedAt != 3000 {
t.Fatalf("next page = %+v, want [4000 3000]", next) t.Fatalf("next page = %+v, want [4000 3000]", next)
} }
// Past the start → empty. // 越过起点 → 空。
tail, err := st.List(ctx, MainSessionID, 1000, 50, "") tail, err := st.List(ctx, MainSessionID, 1000, 50, "")
if err != nil { if err != nil {
t.Fatalf("tail: %v", err) t.Fatalf("tail: %v", err)
@@ -302,7 +300,7 @@ func TestNewSubSessionAndListSessions(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// Main session exists by default. // 主会话默认存在。
sessions, err := st.ListSessions(ctx) sessions, err := st.ListSessions(ctx)
if err != nil { if err != nil {
t.Fatalf("list sessions initial: %v", err) t.Fatalf("list sessions initial: %v", err)
@@ -362,7 +360,7 @@ func TestDeleteSession(t *testing.T) {
t.Fatalf("sessions after delete = %+v, want [main]", sessions) t.Fatalf("sessions after delete = %+v, want [main]", sessions)
} }
// Main session is immutable. // 主会话不可变。
if err := st.DeleteSession(ctx, MainSessionID); !errors.Is(err, ErrImmutableMain) { if err := st.DeleteSession(ctx, MainSessionID); !errors.Is(err, ErrImmutableMain) {
t.Fatalf("delete main err = %v, want ErrImmutableMain", err) t.Fatalf("delete main err = %v, want ErrImmutableMain", err)
} }
@@ -373,21 +371,21 @@ func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// An isolated group session carries the sender display name (design/16 §4.6). // 隔离的群聊会话携带发送者显示名(design/16 §4.6)。
groupID := "sess-g" groupID := "sess-g"
if err := st.Append(ctx, Message{SessionID: groupID, Role: RoleUser, Content: "hello all", Platform: "telegram", SenderName: "张三", DeviceID: "telegram:-20:9", CreatedAt: 1000}); err != nil { if err := st.Append(ctx, Message{SessionID: groupID, Role: RoleUser, Content: "hello all", Platform: "telegram", SenderName: "张三", DeviceID: "telegram:-20:9", CreatedAt: 1000}); err != nil {
t.Fatalf("append group msg: %v", err) t.Fatalf("append group msg: %v", err)
} }
// An owner-private message on the main session carries no sender name. // 主会话上主人的私聊消息不携带发送者名。
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi", Platform: "telegram", CreatedAt: 900}); err != nil { if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi", Platform: "telegram", CreatedAt: 900}); err != nil {
t.Fatalf("append main msg: %v", err) t.Fatalf("append main msg: %v", err)
} }
// An unrelated platform on the main session should not leak into the log. // 主会话上一个无关平台不得泄进该日志。
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "web msg", Platform: "web", CreatedAt: 800}); err != nil { if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "web msg", Platform: "web", CreatedAt: 800}); err != nil {
t.Fatalf("append web msg: %v", err) t.Fatalf("append web msg: %v", err)
} }
// List reads the sender_name back. // List 读回 sender_name。
ls, err := st.List(ctx, groupID, 0, 50, "") ls, err := st.List(ctx, groupID, 0, 50, "")
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
@@ -396,7 +394,7 @@ func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
t.Fatalf("list = %+v, want SenderName 张三", ls) t.Fatalf("list = %+v, want SenderName 张三", ls)
} }
// Recent reads it too. // Recent 也读它。
rs, err := st.Recent(ctx, groupID, 5) rs, err := st.Recent(ctx, groupID, 5)
if err != nil { if err != nil {
t.Fatalf("recent: %v", err) t.Fatalf("recent: %v", err)
@@ -405,8 +403,8 @@ func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
t.Fatalf("recent = %+v, want SenderName 张三", rs) t.Fatalf("recent = %+v, want SenderName 张三", rs)
} }
// Platform log surfaces only the isolated (non-main) messages of the platform, // 平台日志只浮现该平台的隔离(非主)消息,最新优先,排除主会话上的主人
// newest first, excluding the owner-private mirror on the main session. // 私聊镜像。
log, err := st.ListPlatformLog(ctx, "telegram", 0, 50) log, err := st.ListPlatformLog(ctx, "telegram", 0, 50)
if err != nil { if err != nil {
t.Fatalf("platform log: %v", err) t.Fatalf("platform log: %v", err)
@@ -418,7 +416,7 @@ func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
t.Fatalf("platform log[0] = %+v, want group session with SenderName 张三", log[0]) t.Fatalf("platform log[0] = %+v, want group session with SenderName 张三", log[0])
} }
// A platform with only main-session messages yields an empty log. // 只有主会话消息的平台得到空日志。
if log, err := st.ListPlatformLog(ctx, "web", 0, 50); err != nil || len(log) != 0 { if log, err := st.ListPlatformLog(ctx, "web", 0, 50); err != nil || len(log) != 0 {
t.Fatalf("web platform log = %+v, err=%v; want empty", log, err) t.Fatalf("web platform log = %+v, err=%v; want empty", log, err)
} }
@@ -429,8 +427,8 @@ func TestAdapterLogFiltersByAdapter(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// Seed across two adapters, an empty-adapter web message, and a main-session // 跨两个适配器、一条空适配器的 web 消息,以及一条仍携带适配器的主会话
// owner-private mirror that still carries the adapter (design/16 §4.4). // 主人私聊镜像播种(design/16 §4.4)。
for _, m := range []Message{ for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000}, {SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
{SessionID: "sess-g1", Role: RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000}, {SessionID: "sess-g1", Role: RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
@@ -442,8 +440,7 @@ func TestAdapterLogFiltersByAdapter(t *testing.T) {
} }
} }
// Per-adapter log includes BOTH the owner-private main mirror and the group // 按适配器日志同时包含主人私聊的主会话镜像与群聊消息,最新优先。
// message, newest first.
kook, err := st.ListAdapterLog(ctx, "kook:3bef", 0, 50) kook, err := st.ListAdapterLog(ctx, "kook:3bef", 0, 50)
if err != nil { if err != nil {
t.Fatalf("adapter log kook:3bef: %v", err) t.Fatalf("adapter log kook:3bef: %v", err)
@@ -458,7 +455,7 @@ func TestAdapterLogFiltersByAdapter(t *testing.T) {
t.Fatalf("kook[1] = %+v, want owner mirror on main session", kook[1]) t.Fatalf("kook[1] = %+v, want owner mirror on main session", kook[1])
} }
// A different adapter yields only its own lane. // 另一个适配器只得到它自己的通道。
tg, err := st.ListAdapterLog(ctx, "telegram:conf0", 0, 50) tg, err := st.ListAdapterLog(ctx, "telegram:conf0", 0, 50)
if err != nil { if err != nil {
t.Fatalf("adapter log telegram:conf0: %v", err) t.Fatalf("adapter log telegram:conf0: %v", err)
@@ -467,7 +464,7 @@ func TestAdapterLogFiltersByAdapter(t *testing.T) {
t.Fatalf("telegram adapter log = %+v, want [tg-group]", tg) t.Fatalf("telegram adapter log = %+v, want [tg-group]", tg)
} }
// An unknown / empty adapter yields a non-nil empty slice. // 未知 / 空适配器得到非 nil 的空切片。
none, err := st.ListAdapterLog(ctx, "kook:unknown", 0, 50) none, err := st.ListAdapterLog(ctx, "kook:unknown", 0, 50)
if err != nil { if err != nil {
t.Fatalf("adapter log unknown: %v", err) t.Fatalf("adapter log unknown: %v", err)
@@ -483,7 +480,7 @@ func TestAdapterLogFiltersByAdapter(t *testing.T) {
t.Fatalf("empty adapter log = %#v, want non-nil empty slice", empty) t.Fatalf("empty adapter log = %#v, want non-nil empty slice", empty)
} }
// The before cursor pages backward through the adapter's stream. // before 游标在该适配器的消息流中向后翻页。
older, err := st.ListAdapterLog(ctx, "kook:3bef", 2000, 50) older, err := st.ListAdapterLog(ctx, "kook:3bef", 2000, 50)
if err != nil { if err != nil {
t.Fatalf("adapter log before: %v", err) t.Fatalf("adapter log before: %v", err)
@@ -508,7 +505,7 @@ func TestAllLogSurfacesOnlyAdapterMessages(t *testing.T) {
} }
} }
// The overview only surfaces IM adapter sources; the web message is excluded. // 总览只浮现 IM 适配器来源;web 消息被排除。
all, err := st.ListAllLog(ctx, 0, 50) all, err := st.ListAllLog(ctx, 0, 50)
if err != nil { if err != nil {
t.Fatalf("all log: %v", err) t.Fatalf("all log: %v", err)
@@ -519,14 +516,14 @@ func TestAllLogSurfacesOnlyAdapterMessages(t *testing.T) {
if all[0].Content != "tg-group" || all[1].Content != "kook-owner" { if all[0].Content != "tg-group" || all[1].Content != "kook-owner" {
t.Fatalf("all log order = %+v, want [tg-group kook-owner]", all) t.Fatalf("all log order = %+v, want [tg-group kook-owner]", all)
} }
// The web message never leaks into the adapter overview. // web 消息绝不泄进适配器总览。
for _, m := range all { for _, m := range all {
if m.Content == "web-msg" { if m.Content == "web-msg" {
t.Fatalf("web message leaked into adapter overview: %+v", m) t.Fatalf("web message leaked into adapter overview: %+v", m)
} }
} }
// An empty store yields a non-nil empty slice. // 空存储得到非 nil 的空切片。
st2 := newStore(t) st2 := newStore(t)
e, err := st2.ListAllLog(ctx, 0, 50) e, err := st2.ListAllLog(ctx, 0, 50)
if err != nil { if err != nil {
+20 -25
View File
@@ -7,30 +7,27 @@ import (
"strings" "strings"
) )
// This file owns the two session-title paths: auto-naming a session from its first // 本文件负责两条会话标题路径:从第一条用户消息为会话自动命名,以及显式
// user message and an explicit rename. A title is display-only metadata (design/16 // 重命名。标题只是展示元数据(design/16 §2.1),绝不用于标识会话、发送者
// §2.1) and is never used to identify a session, a sender or a permission — identity // 或权限 —— 身份永远来自渠道 actor 元数据 / JWT(项目红线)。
// always comes from the channel actor metadata / JWT (project red line).
const ( const (
// autoTitleMaxRunes bounds an auto-generated title so a session list row never // autoTitleMaxRunes 限制自动生成标题的长度,使会话列表行不会溢出;
// overflows; a longer first message is truncated with an ellipsis. // 更长的首条消息会被截断并加省略号。
autoTitleMaxRunes = 20 autoTitleMaxRunes = 20
// titleMainFallback and titleSubFallback name a session whose first user // titleMainFallback 与 titleSubFallback 用于给首条用户消息没有任何
// message carries no text (e.g. an image-only turn), so the session list is // 文本的会话(例如纯图片回合)命名,使会话列表不会无名。
// never nameless.
titleMainFallback = "主会话" titleMainFallback = "主会话"
titleSubFallback = "次级会话" titleSubFallback = "次级会话"
) )
// ErrSessionNotFound is returned by RenameSession when no chat_sessions row carries // ErrSessionNotFound 在没有任何 chat_sessions 行携带所请求 id 时由
// the requested id. // RenameSession 返回。
var ErrSessionNotFound = errors.New("history: session not found") var ErrSessionNotFound = errors.New("history: session not found")
// RenameSession sets the display title of sessionID. Both the fixed main session // RenameSession 设置 sessionID 的显示标题。固定主会话(id "web")与子会话
// (id "web") and sub sessions may be renamed: renaming is not a destructive identity // 都可重命名:与删除不同,重命名不是破坏性的身份变更。未知 id 返回
// change, unlike deletion. An unknown id returns ErrSessionNotFound. The caller // ErrSessionNotFound。标题由调用方校验(非空、限长);存储原样持久化它。
// validates the title (non-empty, length-bounded); the store persists it verbatim.
func (s *Store) RenameSession(ctx context.Context, sessionID, title string) error { func (s *Store) RenameSession(ctx context.Context, sessionID, title string) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET title = ? WHERE id = ?`, title, sessionID) res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET title = ? WHERE id = ?`, title, sessionID)
if err != nil { if err != nil {
@@ -46,12 +43,11 @@ func (s *Store) RenameSession(ctx context.Context, sessionID, title string) erro
return nil return nil
} }
// ensureTitle names a session from its first user message. It is a no-op unless the // ensureTitle 用第一条用户消息为会话命名。除非该会话行已存在于
// session row exists in chat_sessions, its title is still empty, and no user message // chat_sessions、其标题仍为空、且尚未为它持久化任何用户消息(因此调用方
// has been persisted for it yet (so the caller must invoke it before inserting the // 必须在插入消息之前调用它),否则它是空操作。纯空白的消息回退到会话类型
// message). A message that is all whitespace falls back to the session-type label. // 标签。标题只是展示元数据,因此失败只记日志并吞掉,而不是让它伴随的
// The title is display-only metadata, so a failure is logged and swallowed rather // 消息写入失败。
// than failing the message write it accompanies.
func (s *Store) ensureTitle(ctx context.Context, sessionID, content string) { func (s *Store) ensureTitle(ctx context.Context, sessionID, content string) {
if sessionID == "" { if sessionID == "" {
return return
@@ -68,10 +64,9 @@ func (s *Store) ensureTitle(ctx context.Context, sessionID, content string) {
} }
} }
// deriveTitle turns a user message into a session title: whitespace runs (newlines // deriveTitle 把一条用户消息变成会话标题:空白串(含换行)折叠为单个空格,
// included) collapse to a single space, then the result is truncated to // 再把结果截断到 autoTitleMaxRunes 个 rune 并加省略号。纯空白的消息得到
// autoTitleMaxRunes runes with an ellipsis. A message that is all whitespace yields // 空串,使 ensureTitle 回退到会话类型标签。
// "" so ensureTitle falls back to the session-type label.
func deriveTitle(content string) string { func deriveTitle(content string) string {
fields := strings.Fields(content) fields := strings.Fields(content)
if len(fields) == 0 { if len(fields) == 0 {
+12 -12
View File
@@ -7,7 +7,7 @@ import (
"testing" "testing"
) )
// titleOf returns the stored title of a session, failing the test when it is absent. // titleOf 返回某个会话已存储的标题,缺失时使测试失败。
func titleOf(t *testing.T, st *Store, id string) string { func titleOf(t *testing.T, st *Store, id string) string {
t.Helper() t.Helper()
sessions, err := st.ListSessions(context.Background()) sessions, err := st.ListSessions(context.Background())
@@ -28,12 +28,12 @@ func TestEnsureTitleFromFirstUserMessage(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// The main session starts nameless. // 主会话初始无名。
if got := titleOf(t, st, MainSessionID); got != "" { if got := titleOf(t, st, MainSessionID); got != "" {
t.Fatalf("initial main title = %q, want empty", got) t.Fatalf("initial main title = %q, want empty", got)
} }
// The first user message names it, with whitespace runs collapsed. // 第一条用户消息为它命名,空白串被折叠。
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: " 你好\n世界 "}); err != nil { if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: " 你好\n世界 "}); err != nil {
t.Fatalf("append first user: %v", err) t.Fatalf("append first user: %v", err)
} }
@@ -41,7 +41,7 @@ func TestEnsureTitleFromFirstUserMessage(t *testing.T) {
t.Fatalf("main title = %q, want 你好 世界", got) t.Fatalf("main title = %q, want 你好 世界", got)
} }
// A later user message does not overwrite the established title. // 之后的用户消息不会覆盖已确定的标题。
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "second"}); err != nil { if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "second"}); err != nil {
t.Fatalf("append second user: %v", err) t.Fatalf("append second user: %v", err)
} }
@@ -55,7 +55,7 @@ func TestEnsureTitleTruncatesAndFallsBack(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// A long first user message is truncated to 20 runes plus an ellipsis. // 过长的首条用户消息被截断到 20 个 rune 并加省略号。
sub, err := st.NewSubSession(ctx, "owner") sub, err := st.NewSubSession(ctx, "owner")
if err != nil { if err != nil {
t.Fatalf("new sub: %v", err) t.Fatalf("new sub: %v", err)
@@ -69,8 +69,8 @@ func TestEnsureTitleTruncatesAndFallsBack(t *testing.T) {
t.Fatalf("sub title = %q, want %q", got, want) t.Fatalf("sub title = %q, want %q", got, want)
} }
// An all-whitespace first message (e.g. an image-only turn) falls back to the // 纯空白的首条消息(例如纯图片回合)回退到会话类型标签,子会话与主会话
// session-type label, for both a sub and the main session. // 都是如此。
blank, err := st.NewSubSession(ctx, "owner") blank, err := st.NewSubSession(ctx, "owner")
if err != nil { if err != nil {
t.Fatalf("new blank sub: %v", err) t.Fatalf("new blank sub: %v", err)
@@ -96,7 +96,7 @@ func TestEnsureTitleOnlyFromFirstUserMessage(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// An assistant message first does not name the session. // 首条是 assistant 消息不会为会话命名。
sub, err := st.NewSubSession(ctx, "owner") sub, err := st.NewSubSession(ctx, "owner")
if err != nil { if err != nil {
t.Fatalf("new sub: %v", err) t.Fatalf("new sub: %v", err)
@@ -108,7 +108,7 @@ func TestEnsureTitleOnlyFromFirstUserMessage(t *testing.T) {
t.Fatalf("sub title after assistant = %q, want empty", got) t.Fatalf("sub title after assistant = %q, want empty", got)
} }
// The first user message afterwards still names it. // 其后的第一条用户消息仍会为它命名。
if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "real question"}); err != nil { if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "real question"}); err != nil {
t.Fatalf("append user: %v", err) t.Fatalf("append user: %v", err)
} }
@@ -122,7 +122,7 @@ func TestRenameSession(t *testing.T) {
st := newStore(t) st := newStore(t)
ctx := context.Background() ctx := context.Background()
// The fixed main session may be renamed (unlike deletion). // 固定主会话可以重命名(与删除不同)。
if err := st.RenameSession(ctx, MainSessionID, "工作台"); err != nil { if err := st.RenameSession(ctx, MainSessionID, "工作台"); err != nil {
t.Fatalf("rename main: %v", err) t.Fatalf("rename main: %v", err)
} }
@@ -130,7 +130,7 @@ func TestRenameSession(t *testing.T) {
t.Fatalf("main title = %q, want 工作台", got) t.Fatalf("main title = %q, want 工作台", got)
} }
// A sub session may be renamed, and a rename overwrites an auto title. // 子会话可以重命名,且重命名会覆盖自动标题。
sub, err := st.NewSubSession(ctx, "owner") sub, err := st.NewSubSession(ctx, "owner")
if err != nil { if err != nil {
t.Fatalf("new sub: %v", err) t.Fatalf("new sub: %v", err)
@@ -145,7 +145,7 @@ func TestRenameSession(t *testing.T) {
t.Fatalf("sub title = %q, want 手动标题", got) t.Fatalf("sub title = %q, want 手动标题", got)
} }
// An unknown id is reported as not found. // 未知 id 报 not found。
if err := st.RenameSession(ctx, "web:missing", "x"); !errors.Is(err, ErrSessionNotFound) { if err := st.RenameSession(ctx, "web:missing", "x"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("rename unknown err = %v, want ErrSessionNotFound", err) t.Fatalf("rename unknown err = %v, want ErrSessionNotFound", err)
} }