feat(chat): chat_messages.turn_id 列 + DTO(写入接线留待下一步)
迁移 0018:ALTER TABLE chat_messages ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''
(与 persona_id / client_msg_id / document 同族:不用 NULL,空串 = 不属于任何已知回合 ⇒ 旧行零迁移、键省略)。
存储层全链路带 turn_id(列清单唯一一份 ⇒ List/ListAfter/Recent/Search/FindByClientMsgID 自动同源);
新增 history/turn_id.go:ensureTurnIDColumn(既有库幂等前滚)+ SetMessagesTurnID
(第二段补写:单事务参数化、先到先得 WHERE turn_id = ''、幂等、未知 id 忽略)。
DTO:historyMessage.TurnID(json turnId,omitempty)⇒ REST 列表 / admin 渠道日志 / WS 握手回放 /
message_persisted / channel_persisted / message_edited 一次全覆盖。
写入面:persistUserMessages 读 ctx 回合标识;persistAssistantReply 增可选尾参(既有调用点零改动);
withTurnID / turnIDFrom / assignTurnID / turnIDOfLeg。
⚠️ 编排器把 turnId 交给落库的 4 处(新起一路 / 续跑路 / 被 steer 吃进 / 助手回复)尚未接线
(那些文件当时被另一路占用);当前生产行为 turn_id 仍为 ''。清单已在切片报告里精确到文件:行。
13 新用例 + 5 条变异(INSERT 不带列 / DTO 不回填 / 去掉 omitempty / 去掉先到先得 / 不回滚前滚)。
为守 session_persist.go ≤500 行,turnId 一节拆到新文件 session_turn_id.go。
This commit is contained in:
1 parent
354b08a45f
commit
d64b26d499
12 files changed
+950
-16
No files matched your search
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// 本文件归属聊天消息携带的**文档引用**(design/24 §2.4),以及 chat_messages
|
||||
// 上「建表之后才引入的列」的前滚聚合(client_msg_id 与 document)。
|
||||
// 上「建表之后才引入的列」的前滚聚合(client_msg_id、document 与 turn_id)。
|
||||
//
|
||||
// document 列只存「引用 + 本回合注入统计」——存储名、嗅探所得 MIME、字节数、注入
|
||||
// 字符数、注入是否被截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 →
|
||||
@@ -202,7 +202,10 @@ func ensureMessageColumns(ctx context.Context, db *sql.DB) error {
|
||||
if err := ensureClientMsgIDColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureDocumentColumn(ctx, db)
|
||||
if err := ensureDocumentColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTurnIDColumn(ctx, db)
|
||||
}
|
||||
|
||||
// ensureDocumentColumn 在 chat_messages 早于本列(0015 迁移)建表时把它前滚:
|
||||
|
||||
@@ -167,9 +167,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger, opts ...StoreOp
|
||||
}
|
||||
log = logger.WithSource(discardLogger(log), "history")
|
||||
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
|
||||
// ensureMessageColumns 把早于这些列(client_msg_id 与 document)建出的既有表
|
||||
// 前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不存在时
|
||||
// 建它必然失败。
|
||||
// ensureMessageColumns 把早于这些列(client_msg_id / document / turn_id)建出的
|
||||
// 既有表前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不
|
||||
// 存在时建它必然失败。
|
||||
for _, ddl := range []string{
|
||||
createSessionsTable,
|
||||
createMessagesTable,
|
||||
@@ -244,8 +244,8 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
s.ensureTitle(ctx, msg.SessionID, msg.Content)
|
||||
}
|
||||
query := `INSERT INTO chat_messages
|
||||
(id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
(id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
if msg.ClientMsgID != "" {
|
||||
query += ` ON CONFLICT DO NOTHING`
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
res, err := tx.ExecContext(ctx, query,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image,
|
||||
msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID,
|
||||
msg.PersonaID, msg.ClientMsgID, msg.CreatedAt)
|
||||
msg.PersonaID, msg.ClientMsgID, msg.TurnID, msg.CreatedAt)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
@@ -347,13 +347,13 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
// **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。
|
||||
// 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在
|
||||
// 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。
|
||||
const messageColumns = `id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at`
|
||||
const messageColumns = `id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, turn_id, created_at`
|
||||
|
||||
// scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, platform, deviceID, senderName, adapterID string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.CreatedAt); err != nil {
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.TurnID, &m.CreatedAt); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
|
||||
@@ -420,6 +420,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
adapter_id TEXT NOT NULL DEFAULT '',
|
||||
persona_id TEXT NOT NULL DEFAULT '',
|
||||
client_msg_id TEXT NOT NULL DEFAULT '',
|
||||
turn_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件归属聊天消息的**回合标识**列(design/15 §2.5.9):一条消息属于哪一路回合。
|
||||
//
|
||||
// 它与会话编排器分摊同一件事:编排器是那个**生成**权威 turnId 的地方(每个在飞的
|
||||
// 路一份,挂进 loop.WithTurnID 并盖在该路全部流帧上),存储只是把它落进行里,使
|
||||
// 「回复 ↔ 提问」的配对在**重连 / 回放**时依然成立 —— 流帧是易失的,历史行不是。
|
||||
//
|
||||
// 写入是**两段式**,与 document 列完全同一手法:
|
||||
// - 消息落库时它所属的路已经存在(编排器新起的一路、以及该路的助手回复)⇒ 随
|
||||
// INSERT 一起写入(Message.TurnID),因此 `message_persisted` 那一帧也带着它;
|
||||
// - 排队(忙时入队)或**被转向(steer)吃进**的消息在落库那一刻**还没有自己的
|
||||
// 路** —— 用户消息在入队时就落库,而它由哪一路回答要等那一轮才定。这种行的
|
||||
// turn_id 只能在它**实际被处理的那一路**起跑 / 消费时补写,即本文件的
|
||||
// SetMessagesTurnID。
|
||||
//
|
||||
// 空串(列的默认值)意味着「这一行不属于任何已知的回合」:加入该列之前的历史行、
|
||||
// 未经编排器的写入路径(IM 镜像)都是这种情形,读起来与以往逐字相同。
|
||||
|
||||
// SetMessagesTurnID 把 ids 所指的消息行的 turn_id 落定为 turnID —— 两段式写入的
|
||||
// 第二段(起跑 / 被 steer 消费时补写)。
|
||||
//
|
||||
// 三条纪律:
|
||||
// - **先到先得**:只写给仍然是空串的行(`AND turn_id = ”`)。一行只属于一路,
|
||||
// 后来者绝不改写已经带标识的行 —— 否则「被哪一路回答」会随结算顺序漂移;
|
||||
// - **幂等**:重复补写同一条(重启、重试、同一批消息被两处补写)是空操作;
|
||||
// - **参数化**:id 列表按占位符展开,绝不把值拼进 SQL。
|
||||
//
|
||||
// 空 ids 或空 turnID 是空操作:调用方在「这一路没有落库任何用户消息」或「这一路
|
||||
// 没有回合标识」(未接编排器的旧路径)时不必先判断。未知 id 被忽略而不是报错 ——
|
||||
// 第二段的常态是「刚落的行」,一行找不到只可能是它已被删除,那不是本次写入的失败。
|
||||
func (s *Store) SetMessagesTurnID(ctx context.Context, ids []string, turnID string) error {
|
||||
unique := make([]string, 0, len(ids))
|
||||
seen := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 || turnID == "" {
|
||||
return nil
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(unique)), ", ")
|
||||
args := make([]any, 0, len(unique)+1)
|
||||
args = append(args, turnID)
|
||||
for _, id := range unique {
|
||||
args = append(args, id)
|
||||
}
|
||||
query := `UPDATE chat_messages SET turn_id = ? WHERE turn_id = '' AND id IN (` + placeholders + `)`
|
||||
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
||||
return fmt.Errorf("history: set turn id for %d messages: %w", len(unique), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTurnIDColumn 在 chat_messages 早于本列(0018 迁移)建表时把它前滚:缺列则
|
||||
// 补加,已有则忽略重复列错误。它是幂等的,因此 NewStore 可在全新 schema 或既有
|
||||
// schema 上调用(与 ensureClientMsgIDColumn / ensureDocumentColumn 同一手法)。
|
||||
func ensureTurnIDColumn(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE chat_messages
|
||||
ADD COLUMN turn_id TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// 列已存在时(由 createMessagesTable 建出,或上一次运行补过)SQLite 报
|
||||
// "duplicate column name: turn_id"。
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("history: roll forward turn_id column: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件锁定聊天消息的 **turn_id** 列(design/15 §2.5.9):它回答「这一行属于哪
|
||||
// 一路回合」,使客户端在重连 / 回放时仍能精确配对「回复 ↔ 提问」。
|
||||
//
|
||||
// 三条语义被逐条钉住:
|
||||
// - 落库时已经知道所属路的消息(新起的一路 + 它的助手回复)随 INSERT 写入,
|
||||
// 同一路两条**同值**、两路**不同值**;
|
||||
// - 落库时还不知道所属路的消息(忙时入队 / 被 steer 吃进)留空串,由它实际被
|
||||
// 处理的那一路**补写**(SetMessagesTurnID),且**先到先得**(已带标识的行不被
|
||||
// 改写 —— 一行只属于一路);
|
||||
// - 省略标识与加入本列之前逐字相同:空串默认值、读路径照常工作。
|
||||
|
||||
func TestAppendFullRoundTripsTurnID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := newStore(t)
|
||||
|
||||
user, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "先问你一句", TurnID: "turn-leg1", CreatedAt: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("append user: %v", err)
|
||||
}
|
||||
reply, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "答你一句", TurnID: "turn-leg1", CreatedAt: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("append assistant: %v", err)
|
||||
}
|
||||
// 第二路(并行)带自己的标识与自己的两条消息。
|
||||
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "另一路的问题", TurnID: "turn-leg2", CreatedAt: 3000}); err != nil {
|
||||
t.Fatalf("append parallel user: %v", err)
|
||||
}
|
||||
|
||||
if user.TurnID != "turn-leg1" || reply.TurnID != "turn-leg1" {
|
||||
t.Fatalf("写入返回值 = %q / %q, want turn-leg1(同一路两条同值)", user.TurnID, reply.TurnID)
|
||||
}
|
||||
|
||||
// 读路径(共用 messageColumns/scanMessage)必须把标识带出来。
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
got := make(map[string]string, len(msgs))
|
||||
for _, m := range msgs {
|
||||
got[m.Content] = m.TurnID
|
||||
}
|
||||
if got["先问你一句"] != "turn-leg1" || got["答你一句"] != "turn-leg1" {
|
||||
t.Fatalf("同一路两条 = %q / %q, want 都是 turn-leg1", got["先问你一句"], got["答你一句"])
|
||||
}
|
||||
if got["另一路的问题"] != "turn-leg2" {
|
||||
t.Fatalf("另一路 = %q, want turn-leg2(两路绝不同值)", got["另一路的问题"])
|
||||
}
|
||||
|
||||
// 其余读面同源(它们共用同一份列清单,这里抽验两条最容易漏改的)。
|
||||
recent, err := st.Recent(ctx, MainSessionID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("recent: %v", err)
|
||||
}
|
||||
if len(recent) != 1 || recent[0].TurnID != "turn-leg2" {
|
||||
t.Fatalf("recent = %+v, want 最新那条带 turn-leg2", recent)
|
||||
}
|
||||
hits, err := st.Search(ctx, MainSessionID, "先问你一句", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].TurnID != "turn-leg1" {
|
||||
t.Fatalf("search = %+v, want 命中那条带 turn-leg1", hits)
|
||||
}
|
||||
found, err := st.FindByClientMsgID(ctx, MainSessionID, "")
|
||||
if err == nil {
|
||||
t.Fatalf("FindByClientMsgID(空键) = %+v, want ErrMessageNotFound", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendWithoutTurnIDLeavesItEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := newStore(t)
|
||||
|
||||
// 省略标识(老路径 / IM 镜像 / 排队入队)与加入本列之前逐字相同:空串。
|
||||
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "没有回合标识", CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].TurnID != "" {
|
||||
t.Fatalf("msgs = %+v, want 空 turn_id(省略 ⇒ 不属于任何已知回合)", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMessagesTurnIDAssignsUnassignedOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := newStore(t)
|
||||
|
||||
// 两行在落库时都还没有所属的路(忙时入队 / 被 steer 吃进的那种行)。
|
||||
queued, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "排队的一句", CreatedAt: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("append queued: %v", err)
|
||||
}
|
||||
steered, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "被转向吃进的一句", CreatedAt: 1500})
|
||||
if err != nil {
|
||||
t.Fatalf("append steered: %v", err)
|
||||
}
|
||||
|
||||
// 第二段:由**实际处理它们的那一路**补写。
|
||||
if err := st.SetMessagesTurnID(ctx, []string{queued.ID, steered.ID}, "turn-actual"); err != nil {
|
||||
t.Fatalf("set turn id: %v", err)
|
||||
}
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.TurnID != "turn-actual" {
|
||||
t.Fatalf("消息 %q 的 turn_id = %q, want turn-actual(按实际处理它的那一路写)", m.Content, m.TurnID)
|
||||
}
|
||||
}
|
||||
|
||||
// 先到先得:已经带上标识的行绝不被后来者改写(一行只属于一路)。
|
||||
if err := st.SetMessagesTurnID(ctx, []string{queued.ID}, "turn-later"); err != nil {
|
||||
t.Fatalf("set turn id again: %v", err)
|
||||
}
|
||||
msgs, err = st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list after re-set: %v", err)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.TurnID != "turn-actual" {
|
||||
t.Fatalf("消息 %q 的 turn_id 被改写成 %q, want 仍是 turn-actual", m.Content, m.TurnID)
|
||||
}
|
||||
}
|
||||
|
||||
// 未知 id 被忽略而不是报错:第二段的常态是「刚落的行」,找不到只可能是它已被删除。
|
||||
if err := st.SetMessagesTurnID(ctx, []string{"不存在的 id"}, "turn-actual"); err != nil {
|
||||
t.Fatalf("unknown id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMessagesTurnIDNoopInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := newStore(t)
|
||||
row, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "保持空", CreatedAt: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
// 空 id 列表、空标识、只含空串的列表都是空操作(调用方不必先判断)。
|
||||
cases := []struct {
|
||||
name string
|
||||
ids []string
|
||||
turnID string
|
||||
}{
|
||||
{name: "no ids", ids: nil, turnID: "turn-x"},
|
||||
{name: "no turn id", ids: []string{row.ID}, turnID: ""},
|
||||
{name: "blank ids", ids: []string{"", ""}, turnID: "turn-x"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if err := st.SetMessagesTurnID(ctx, tc.ids, tc.turnID); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].TurnID != "" {
|
||||
t.Fatalf("msgs = %+v, want 空 turn_id(空输入不写任何东西)", msgs)
|
||||
}
|
||||
|
||||
// 重复 id 只算一次,且整批落到同一个标识上。
|
||||
if err := st.SetMessagesTurnID(ctx, []string{row.ID, row.ID, "", row.ID}, "turn-dup"); err != nil {
|
||||
t.Fatalf("duplicate ids: %v", err)
|
||||
}
|
||||
msgs, err = st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list after dup: %v", err)
|
||||
}
|
||||
if msgs[0].TurnID != "turn-dup" {
|
||||
t.Fatalf("turn_id = %q, want turn-dup", msgs[0].TurnID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewStoreRollsForwardTurnIDColumn 验证早于 0018 建出的 chat_messages 表会被
|
||||
// NewStore 幂等前滚出 turn_id 列(与 client_msg_id / document 同一手法):全新库由
|
||||
// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带标识的消息。
|
||||
func TestNewStoreRollsForwardTurnIDColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
// 0018 之前的形状:有 document(0015)但没有 turn_id。
|
||||
const legacyTable = `
|
||||
CREATE TABLE chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
mode TEXT,
|
||||
mood TEXT,
|
||||
image TEXT,
|
||||
document TEXT NOT NULL DEFAULT '',
|
||||
thinking TEXT,
|
||||
platform TEXT NOT NULL DEFAULT 'web',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
sender_name TEXT NOT NULL DEFAULT '',
|
||||
adapter_id TEXT NOT NULL DEFAULT '',
|
||||
persona_id TEXT NOT NULL DEFAULT '',
|
||||
client_msg_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
if _, err := conn.ExecContext(ctx, legacyTable); err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
|
||||
st, err := NewStore(ctx, conn, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "前滚之后", TurnID: "turn-rolled", CreatedAt: 2000}); err != nil {
|
||||
t.Fatalf("append after roll forward: %v", err)
|
||||
}
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].TurnID != "turn-rolled" {
|
||||
t.Fatalf("msgs = %+v, want turn-rolled 往返", msgs)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,21 @@ type Message struct {
|
||||
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前
|
||||
// 逐字相同。
|
||||
ClientMsgID string
|
||||
CreatedAt int64
|
||||
// TurnID 是这条消息所属的那一**路**回合的权威标识(design/15 §2.5.9):
|
||||
// 编排器生成,与该路全部流帧 / `turn_begin` 帧壳上的 turnId **同值**。因此
|
||||
// 同一路的 user 行与 assistant 行带同一个值 —— 客户端据此精确配对「回复 ↔
|
||||
// 提问」,并在重连 / 回放时从历史行恢复回复侧的分组小标(真并行下同一会话
|
||||
// 有两个交错的回答,只按顺序渲染会配错)。
|
||||
//
|
||||
// 它可选:空串意味着「这一行不属于任何已知的回合」—— 加入该列之前的行、以及
|
||||
// 未经编排器的写入路径(IM 镜像等)都是这种情形。
|
||||
//
|
||||
// 两个写入时点(与 Document 同一手法,见 turn_id.go):① 消息落库时它所属的
|
||||
// 路已经存在(新起的一路、以及助手回复)⇒ 随 INSERT 写入;② 排队 / 被转向
|
||||
// 吃进的消息在落库那一刻还没有自己的路 ⇒ 由**它实际被处理的那一路**补写同一
|
||||
// 列(先到先得)。
|
||||
TurnID string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// SessionMeta 是一个持久化会话面向线上的元数据。
|
||||
|
||||
Reference in new issue
Block a user