feat(runtime): document 内容类型与持久化(design/24 S2)
- model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 + ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。 - 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest: 本期 document 永不抵达编码器,且绝不静默丢块。 - history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是 落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。 - chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO; session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。 - 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。 验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、 全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。 变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。
This commit is contained in:
1 parent
407c934a05
commit
6b2b07cbef
15 files changed
+895
-101
No files matched your search
@@ -67,82 +67,6 @@ var (
|
||||
ErrMessageNotFound = errors.New("history: message not found")
|
||||
)
|
||||
|
||||
// Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任
|
||||
// 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条
|
||||
// 消息的 created_at。
|
||||
type Message struct {
|
||||
ID string
|
||||
SessionID string
|
||||
Role string
|
||||
Content string
|
||||
Mode string
|
||||
Mood string
|
||||
Image string
|
||||
Thinking string
|
||||
// Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台
|
||||
// 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台
|
||||
// (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认
|
||||
// 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。
|
||||
Platform string
|
||||
// DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出
|
||||
// (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的
|
||||
// "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个
|
||||
// 客户端共享 Platform,但 DeviceID 不同。
|
||||
DeviceID string
|
||||
// SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方
|
||||
// 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者
|
||||
// 是谁;对主会话上主人的私聊它刻意留空(主人从不被标注)。它只是展示
|
||||
// 元数据 —— 身份绝不由该字段判定。
|
||||
SenderName string
|
||||
// AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如
|
||||
// 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸
|
||||
// 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空,
|
||||
// 因此对 Web 消息它恒为空。
|
||||
AdapterID string
|
||||
// PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留
|
||||
// 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨
|
||||
// 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色
|
||||
// 之前的行、以及任何未显式指定角色写入的消息,因此它们读起来与以往完全
|
||||
// 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定
|
||||
// (项目红线)。
|
||||
PersonaID string
|
||||
// ClientMsgID 是客户端为这条消息生成的幂等键(Android 协同 §G4)—— 通常是
|
||||
// 一条乐观气泡的本地 id,也正因此它必须原样回填并可见:客户端用它把本地
|
||||
// 气泡与服务端真相对齐,而不是靠「文本相等」这类脆弱匹配。同一会话内同一
|
||||
// 非空键只允许一条消息(部分唯一索引裁决,见 client_msg_id.go)。空串意味着
|
||||
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前
|
||||
// 逐字相同。
|
||||
ClientMsgID string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// SessionMeta 是一个持久化会话面向线上的元数据。
|
||||
type SessionMeta struct {
|
||||
ID string
|
||||
Type string // "main" | "sub"(主会话 | 子会话)
|
||||
Title string
|
||||
CreatedAt int64
|
||||
// Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限
|
||||
// 绝不由它推导。
|
||||
Pinned bool
|
||||
// LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中
|
||||
// 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求;
|
||||
// 身份与权限绝不由它推导。
|
||||
LastMessage *MessageSummary
|
||||
// UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由
|
||||
// ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端
|
||||
// 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是
|
||||
// 展示用 —— 身份与权限绝不由它推导。
|
||||
UnreadCount int
|
||||
// PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由
|
||||
// 哪个角色应答」的唯一事实来源:循环据此解析记忆作用域、系统提示词、
|
||||
// 情绪与思考预算。为空意味着「继承当前全局默认角色」
|
||||
// (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定
|
||||
// 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随
|
||||
// 默认角色变更(design/22 §7-6)。
|
||||
PersonaID string
|
||||
}
|
||||
|
||||
// HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有
|
||||
// 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际
|
||||
// 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里
|
||||
@@ -228,8 +152,8 @@ type rowScanner interface {
|
||||
}
|
||||
|
||||
// NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在
|
||||
// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,见
|
||||
// client_msg_id.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
|
||||
// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,前滚
|
||||
// 见 document.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
|
||||
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
@@ -237,8 +161,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
}
|
||||
log = logger.WithSource(discardLogger(log), "history")
|
||||
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
|
||||
// ensureClientMsgIDColumn 把早于该列建出的既有表前滚(幂等),最后才建索引
|
||||
// —— 部分唯一索引引用 client_msg_id,列还不存在时建它必然失败。
|
||||
// ensureMessageColumns 把早于这些列(client_msg_id 与 document)建出的既有表
|
||||
// 前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不存在时
|
||||
// 建它必然失败。
|
||||
for _, ddl := range []string{
|
||||
createSessionsTable,
|
||||
createMessagesTable,
|
||||
@@ -247,7 +172,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
return nil, fmt.Errorf("history: apply schema: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ensureClientMsgIDColumn(ctx, db); err != nil {
|
||||
if err := ensureMessageColumns(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ddl := range []string{
|
||||
@@ -306,14 +231,15 @@ 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, 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, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
if msg.ClientMsgID != "" {
|
||||
query += ` ON CONFLICT DO NOTHING`
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, query,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.ClientMsgID, msg.CreatedAt)
|
||||
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)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
@@ -418,13 +344,13 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
// **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。
|
||||
// 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在
|
||||
// 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。
|
||||
const messageColumns = `id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at`
|
||||
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`
|
||||
|
||||
// scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, platform, deviceID, senderName, adapterID string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.CreatedAt); err != nil {
|
||||
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 {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
|
||||
@@ -483,6 +409,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
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 '',
|
||||
|
||||
Reference in new issue
Block a user