feat(runtime): document 内容类型与持久化(design/24 S2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 17:07:24 +08:00
1 parent 407c934a05
commit 6b2b07cbef
15 files changed
+895 -101

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
package db
import (
"context"
"testing"
)
// documentMigrationID 是文档引用列的迁移 id(design/24 §2.4)。
const documentMigrationID = "0015_chat_message_document"
// TestMigrateDocumentColumnReversible 验证 0015(chat_messages.document,文档
// 引用列)可上下翻滚:up 建列、重复 up 是空操作、down 删列、再 up 恢复。
// 它用自己的内存库,绝不碰 data/cyrene.db。
func TestMigrateDocumentColumnReversible(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "document") {
t.Fatal("chat_messages.document should exist after MigrateUp")
}
// 重复 up:已记录的迁移被跳过,列仍在且不报错(幂等)。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "document") {
t.Fatal("chat_messages.document should still exist after idempotent re-run")
}
// 恰好回滚到 0015(含它之后新加的迁移),因此日后再加迁移也不会让本测试失效。
ids := upMigrationIDs(t)
idx := -1
for i, id := range ids {
if id == documentMigrationID {
idx = i
}
}
if idx < 0 {
t.Fatalf("%s not found in %v", documentMigrationID, ids)
}
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
}
if columnExists(t, ctx, conn, "chat_messages", "document") {
t.Fatal("chat_messages.document should be dropped by the 0015 down")
}
// 重新 up 恢复端到端可用:默认空串意味着「这条消息没有附件」。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "document") {
t.Fatal("chat_messages.document should exist again after re-MigrateUp")
}
if _, err := conn.ExecContext(ctx,
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
"m1", "web", "user", "hi", 1000); err != nil {
t.Fatalf("insert without document: %v", err)
}
var doc string
if err := conn.QueryRowContext(ctx, `SELECT document FROM chat_messages WHERE id = ?`, "m1").Scan(&doc); err != nil {
t.Fatalf("select document: %v", err)
}
if doc != "" {
t.Fatalf("document = %q, want the empty default", doc)
}
}
@@ -0,0 +1,8 @@
-- 0015_chat_message_document down: 回滚文档引用列。
--
-- document 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此
-- 直接删列即可。回滚后的状态是「服务端不再记得这条消息带过附件」:列里的引用
-- 与统计随列一起丢弃,而文档本体(uploads/extracted)与元数据表不在本迁移的
-- 范围内,由各自的切片/GC 处置。
ALTER TABLE chat_messages DROP COLUMN document;
@@ -0,0 +1,19 @@
-- 0015_chat_message_document: 给聊天消息挂上「文档引用」(design/24 §2.4)。
--
-- 「分享到 App · 超限走文件」把长文/文件做成服务端文档,再由 chat 以
-- document 引用它。这条消息需要在历史里看得见「她读过这个文件」,因此
-- chat_messages 增一列 document,存一个 JSON 小对象:
-- {name, mime, size, chars, truncated, status}
-- name 是服务端存储名(内容寻址、32 位十六进制)、mime 是服务端字节嗅探所得、
-- chars/truncated 是抽取统计、status 是抽取状态(extracted|listed|unreadable)。
--
-- 它**刻意只存引用与统计,绝不存抽取正文**:正文一旦落库,每翻一页历史都要
-- 背着它重传(G3 把 base64 原样落库的教训),而且附件正文是提示词注入面
-- (design/11 §10)。正文留在 <DataDir>/extracted 的 sidecar 里,装配期按
-- 引用读(design/24 §4.3:只抽一次,不每回合重算)。
--
-- 0003 的 chat_messages 表在此已存在,故 ALTER ADD COLUMN 是非破坏性的正确
-- 一步;全新库由 internal/runtime/history.NewStore 幂等地前滚(含该列)。
-- 表名沿用复数口径(援引 AGENTS §10 已登记的例外)。
ALTER TABLE chat_messages ADD COLUMN document TEXT NOT NULL DEFAULT '';
+101
View File
@@ -0,0 +1,101 @@
package history
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
// 本文件归属聊天消息携带的**文档引用**(design/24 §2.4),以及 chat_messages
// 上「建表之后才引入的列」的前滚聚合(client_msg_id 与 document)。
//
// document 列只存「引用 + 抽取统计」——存储名、嗅探所得 MIME、字节数、抽取
// 字符数、是否截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 → 每页
// 历史背着重传)与 H1(每回合重算)两条教训说的都是同一件事:正文一旦进了
// 历史,每读一页都要为它付一次代价,而且它是提示词注入面(design/11 §10)。
// 文档在持久化与历史 DTO 里可能处于的抽取状态(design/24 §6.1),由装配期
// 与抽取器填入。
const (
// DocumentStatusExtracted 表示正文已抽取并进了上下文。
DocumentStatusExtracted = "extracted"
// DocumentStatusListed 表示只给了模型一份附件清单,正文没有进上下文。
DocumentStatusListed = "listed"
// DocumentStatusUnreadable 表示读不到正文(抽取失败、类型不可解析)。
DocumentStatusUnreadable = "unreadable"
)
// DocumentRef 是一条消息携带的文档引用与抽取统计。它就是 chat_messages.document
// 列里的 JSON 小对象,**同时**也是历史 DTO 里 document 对象的形状:两边共用
// 同一个结构体,所以「不回传正文」是结构上保证的,而不是靠约定或代码审查。
type DocumentRef struct {
// Name 是服务端存储名(design/24 §3.2:32 位十六进制 + 白名单扩展名)。
Name string `json:"name"`
// MIME 是服务端字节嗅探所得,绝不是客户端的声明。
MIME string `json:"mime"`
// Size 是该文档的字节数。
Size int64 `json:"size"`
// Chars 是本次抽取进上下文的字符数(已截断者填截断后的值)。
Chars int `json:"chars,omitempty"`
// Truncated 报告抽取正文是否被预算截断(design/24 §4.3:截断必须诚实
// 标注,绝不静默)。
Truncated bool `json:"truncated,omitempty"`
// Status 是抽取状态,取值见 DocumentStatus* 常量。
Status string `json:"status,omitempty"`
}
// EncodeDocument 把文档引用编码成落库用的 JSON 小对象。空引用返回 "",使
// 「这条消息没有附件」与「有附件」在库里、在 DTO 里都有确定区别。
func EncodeDocument(ref DocumentRef) (string, error) {
if ref == (DocumentRef{}) {
return "", nil
}
b, err := json.Marshal(ref)
if err != nil {
return "", fmt.Errorf("history: encode document ref: %w", err)
}
return string(b), nil
}
// DecodeDocument 解码落库的文档引用。空串(这条消息没有附件)返回 (nil, nil);
// 非法 JSON 返回错误,由调用方决定如何**可见地**报告 —— 解析失败绝不静默当成
// 「没有附件」,那正是 F2 要消灭的那种「一方以为传上去了,另一方一无所知」。
func DecodeDocument(raw string) (*DocumentRef, error) {
if raw == "" {
return nil, nil
}
var ref DocumentRef
if err := json.Unmarshal([]byte(raw), &ref); err != nil {
return nil, fmt.Errorf("history: decode document ref: %w", err)
}
return &ref, nil
}
// ensureMessageColumns 把 chat_messages 上「建表之后才引入的列」前滚到既有表。
// 全新库由 createMessagesTable 一次建全,因此这里对它们都是空操作;早于某列
// 建出的表则在这里补上(与 memory 包的 ensureProjectIDColumn 同一手法)。
func ensureMessageColumns(ctx context.Context, db *sql.DB) error {
if err := ensureClientMsgIDColumn(ctx, db); err != nil {
return err
}
return ensureDocumentColumn(ctx, db)
}
// ensureDocumentColumn 在 chat_messages 早于本列(0015 迁移)建表时把它前滚:
// 缺列则补加,已有则忽略重复列错误。它是幂等的,因此 NewStore 可在全新 schema
// 或既有 schema 上调用。
func ensureDocumentColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE chat_messages
ADD COLUMN document TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 列已存在时(由 createMessagesTable 建出,或上一次运行补过)SQLite 报
// "duplicate column name: document"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("history: roll forward document column: %w", err)
}
+196
View File
@@ -0,0 +1,196 @@
package history
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件是文档引用列(chat_messages.document,design/24 §2.4)的存储测试:
// 引用与统计往返一致、库里**任何一列都不含抽取正文**、坏 JSON 不静默当成
// 「没有附件」,以及早于 0015 建出的旧表能被幂等前滚。
// TestDocumentRefRoundTrip 验证「引用 + 统计」落库再读出后逐字段一致。该列是
// 不透明 JSON:存储不解释、不裁剪它。
func TestDocumentRefRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
ref := DocumentRef{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: DocumentStatusExtracted,
}
raw, err := EncodeDocument(ref)
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "总结一下", Document: raw, 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 {
t.Fatalf("len = %d, want 1", len(msgs))
}
got, err := DecodeDocument(msgs[0].Document)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got == nil {
t.Fatal("document ref lost in round trip")
}
if *got != ref {
t.Fatalf("ref = %+v, want %+v", *got, ref)
}
}
// TestDocumentRefEmptyMeansNoAttachment 验证「没有附件」与「有附件」在库里、
// 在 DTO 里都有确定区别:空引用编码成空串,空串解码回 nil。
func TestDocumentRefEmptyMeansNoAttachment(t *testing.T) {
t.Parallel()
raw, err := EncodeDocument(DocumentRef{})
if err != nil {
t.Fatalf("encode zero ref: %v", err)
}
if raw != "" {
t.Fatalf("raw = %q, want empty column", raw)
}
ref, err := DecodeDocument("")
if err != nil {
t.Fatalf("decode empty: %v", err)
}
if ref != nil {
t.Fatalf("ref = %+v, want nil", ref)
}
}
// TestDecodeDocumentRejectsMalformed 验证坏 JSON 返回错误而不是 (nil, nil):
// 解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要消灭的误解。
func TestDecodeDocumentRejectsMalformed(t *testing.T) {
t.Parallel()
if _, err := DecodeDocument("{not json"); err == nil {
t.Fatal("DecodeDocument(malformed) = nil, want an error")
}
}
// TestPersistedDocumentHoldsNoBody 断言抽取正文绝不落库(design/24 §2.4 的
// 红线):正文留在 <DataDir>/extracted 的 sidecar,历史只存引用与统计。
// 它把 chat_messages 的每一列拼起来扫一遍正文,因此任何一列(包括将来新增的
// 那些)都逃不掉。
func TestPersistedDocumentHoldsNoBody(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
const body = "抽取正文不可落库·SECRET-BODY-0123456789"
raw, err := EncodeDocument(DocumentRef{
Name: "aaaabbbbccccdddd.txt",
MIME: "text/plain",
Size: int64(len(body)),
Chars: len(body),
Status: DocumentStatusExtracted,
})
if err != nil {
t.Fatalf("encode: %v", err)
}
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "这份文档讲了什么", Document: raw})
if err != nil {
t.Fatalf("append: %v", err)
}
const allColumns = `SELECT ifnull(id,'')||ifnull(session_id,'')||ifnull(role,'')||ifnull(content,'')||
ifnull(mode,'')||ifnull(mood,'')||ifnull(image,'')||ifnull(document,'')||ifnull(thinking,'')||
ifnull(platform,'')||ifnull(device_id,'')||ifnull(sender_name,'')||ifnull(adapter_id,'')||
ifnull(persona_id,'')||ifnull(client_msg_id,'')||ifnull(created_at,'')
FROM chat_messages WHERE id = ?`
var all string
if err := st.db.QueryRowContext(ctx, allColumns, full.ID).Scan(&all); err != nil {
t.Fatalf("scan all columns: %v", err)
}
if strings.Contains(all, body) {
t.Fatalf("抽取正文落库了(该行全部列 = %q)", all)
}
if !strings.Contains(all, "aaaabbbbccccdddd.txt") {
t.Fatalf("文档引用应落库,但库里没有(该行全部列 = %q)", all)
}
}
// TestNewStoreRollsForwardDocumentColumn 验证早于 0015 建出的 chat_messages 表
// 会被 NewStore 幂等前滚出 document 列(与 client_msg_id 同一手法):全新库由
// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带引用的消息。
func TestNewStoreRollsForwardDocumentColumn(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() })
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,
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)
}
raw, err := EncodeDocument(DocumentRef{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Status: DocumentStatusListed})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "带附件", Document: raw, 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].Document != raw {
t.Fatalf("msgs = %+v, want the document ref back", msgs)
}
}
// TestDocumentStatusConstants 钉住三个状态取值:它们经持久化直通 DTO,客户端
// 按这些字面量渲染「读过了 / 只有清单 / 读不到」(design/24 §6.1)。
func TestDocumentStatusConstants(t *testing.T) {
t.Parallel()
want := map[string]string{
DocumentStatusExtracted: "extracted",
DocumentStatusListed: "listed",
DocumentStatusUnreadable: "unreadable",
}
for got, expected := range want {
if got != expected {
t.Fatalf("status = %q, want %q", got, expected)
}
}
}
+14 -87
View File
@@ -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 '',
+88
View File
@@ -0,0 +1,88 @@
package history
// 本文件归属**持久化领域类型**:一条已持久化的聊天消息(Message)与会话面向
// 线上的元数据(SessionMeta)。它们从 store.go 拆出来有两条理由:store.go 已经
// 贴着 500 行硬上限(AGENTS §10),而「表里有什么」与「SQL 怎么读写它」本来就
// 是两件事。SQL 实现仍住在 store.go 及其同族文件里。
// 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
// Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4),即
// DocumentRef 序列化出的小对象 {name,mime,size,chars,truncated,status}。
// 它刻意只装引用与统计 —— **绝不装抽取正文**:正文一旦落库,每翻一页
// 历史都要背着它重传一次(G3 的教训),而且它是提示词注入面
// (design/11 §10)。空串表示这条消息没有附件。
Document 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
}
+10 -2
View File
@@ -9,8 +9,13 @@ import (
// 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。
// 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的
// 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不
// 抵达编码器,漏网即编程错误,绝不静默丢块。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return "", nil, err
}
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
@@ -97,7 +102,10 @@ func encodeAnthropicToolResult(m Message) ([]any, error) {
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。
// 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走
// 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document
// 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②,
// fail-loud 绝不静默丢块)。
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
+73
View File
@@ -0,0 +1,73 @@
package model
import (
"errors"
"fmt"
)
// 本文件归属文档内容片段(design/24 §2.2、§2.3):文档的归一化形状、构造器,
// 以及「文档只允许挂在用户回合上」这条红线的校验。
//
// 本期硬规则(design/24 §2.3):**文档部件永不抵达编码器**。装配期会把它
// 确定性替换为抽取所得的文本部件,或保留为「仅清单」条目;因此任何漏网抵达
// 编码器的文档部件都是编程错误,必须报错,绝不允许静默丢块 —— 静默丢块正是
// 「传上去了但模型读不到,且双方都不知道」的来源。
// Document 描述一份服务端签发的文档引用(design/24 §2.2)。四个字段全部来自
// 服务端:Name 是服务端存储名、MIME 是字节嗅探所得、URL 是服务端本地引用、
// Size 是字节数。客户端的原始文件名与声明的 Content-Type 都不在其中 ——
// 按 design/24 §3.2 的「字节嗅探唯一裁决」口径,它们只能作参考且可被否决。
type Document struct {
// Name 是服务端存储名(32 位十六进制 + 白名单扩展名)。
Name string
// MIME 是服务端嗅探所得的媒体类型。
MIME string
// URL 是服务端签发的本地引用(文档读侧)。
URL string
// Size 是该文档的字节数。
Size int64
}
// ErrDocumentOnNonUser 在文档部件挂到非 RoleUser 回合上时返回。「用户内容
// 只进用户角色消息」是项目红线(design/11 §10.2、design/24 §2.3-③),所以
// 这是编程错误,而不是可以忽略的输入问题。
var ErrDocumentOnNonUser = errors.New("model: document content is only allowed on user messages")
// DocumentMessage 构建一条携带可选文本与一份文档引用的消息。文本为空则省略
// 文本部件(与 ImageMessage 同一口径)。
//
// 构造器刻意不校验角色:它是纯函数,角色红线由 ValidateDocumentParts 在编码
// 边界上统一裁决,从而保证「校验只有一处、漏不掉」。落位到非 user 回合的
// 消息会在那里被拒绝,而不会被静默剥掉那块内容。
func DocumentMessage(role Role, text string, doc Document) Message {
parts := make([]Content, 0, 2)
if text != "" {
parts = append(parts, Content{Type: ContentTypeText, Text: text})
}
parts = append(parts, Content{
Type: ContentTypeDocument,
DocumentName: doc.Name,
DocumentMIME: doc.MIME,
DocumentURL: doc.URL,
DocumentSize: doc.Size,
})
return Message{Role: role, Content: parts}
}
// ValidateDocumentParts 校验文档部件只出现在 RoleUser 回合上(design/24
// §2.3-③)。两个编码器都在入口调用它,因此「文档落到了 system/assistant
// 消息上」会当场报错,而不是在那里被悄悄丢掉(Anthropic 的 system 拼接与
// OpenAI 的文本拼接都会忽略非文本部件,静默丢失是默认行为)。
func ValidateDocumentParts(msgs []Message) error {
for _, m := range msgs {
if m.Role == RoleUser {
continue
}
for _, p := range m.Content {
if p.Type == ContentTypeDocument {
return fmt.Errorf("model: %w: document %q on %s", ErrDocumentOnNonUser, p.DocumentName, m.Role)
}
}
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package model
import (
"errors"
"testing"
)
// 本文件是文档内容类型的构造器与两条负例(design/24 §2.2、§2.3):
// ① 文档部件抵达任一编码器 → ErrBadRequest(fail-loud,绝不静默丢块);
// ② 文档部件挂在非 RoleUser 回合上 → ErrDocumentOnNonUser(编程错误)。
// 断言一律用 errors.Is 对哨兵错误,绝不按错误文本子串。
// TestDocumentMessageCarriesReference 验证构造器带上文本与服务端签发的引用。
func TestDocumentMessageCarriesReference(t *testing.T) {
t.Parallel()
doc := Document{Name: "a1b2.txt", MIME: "text/plain", URL: "/api/v1/documents/a1b2.txt", Size: 42}
msg := DocumentMessage(RoleUser, "读一下", doc)
if msg.Role != RoleUser {
t.Fatalf("role = %q, want %q", msg.Role, RoleUser)
}
if len(msg.Content) != 2 || msg.Content[0].Type != ContentTypeText || msg.Content[0].Text != "读一下" {
t.Fatalf("content = %+v, want text + document", msg.Content)
}
part := msg.Content[1]
if part.Type != ContentTypeDocument {
t.Fatalf("part type = %q, want %q", part.Type, ContentTypeDocument)
}
if part.DocumentName != doc.Name || part.DocumentMIME != doc.MIME || part.DocumentURL != doc.URL || part.DocumentSize != doc.Size {
t.Fatalf("document part = %+v, want %+v", part, doc)
}
}
// TestDocumentMessageWithoutText 验证文本为空时只留文档部件(与 ImageMessage
// 同一口径:空文本不造空片段)。
func TestDocumentMessageWithoutText(t *testing.T) {
t.Parallel()
msg := DocumentMessage(RoleUser, "", Document{Name: "a1b2.txt"})
if len(msg.Content) != 1 || msg.Content[0].Type != ContentTypeDocument {
t.Fatalf("content = %+v, want a single document part", msg.Content)
}
}
// TestEncodeContentRejectsDocument 是负例①(OpenAI 侧):未解析的文档部件抵达
// encodeContent 必须返回 ErrBadRequest,而不是被静默跳过(静默丢块会让模型
// 与用户都不知道附件没送到)。
func TestEncodeContentRejectsDocument(t *testing.T) {
t.Parallel()
_, err := encodeContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want wrap of ErrBadRequest", err)
}
}
// TestEncodeAnthropicContentRejectsDocument 是负例①(Anthropic 侧)。
func TestEncodeAnthropicContentRejectsDocument(t *testing.T) {
t.Parallel()
_, err := encodeAnthropicContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want wrap of ErrBadRequest", err)
}
}
// TestBuildMessagesRejectsDocumentOnNonUser 是负例②:文档部件挂到
// assistant/system 回合上是编程错误(design/11 §10.2 的用户内容归属红线),
// 两个编码器都必须报错。这里刻意用 ErrDocumentOnNonUser 而不是 ErrBadRequest
// 断言,以便区分「角色错了」与「类型没人支持」两种失败。
func TestBuildMessagesRejectsDocumentOnNonUser(t *testing.T) {
t.Parallel()
for _, role := range []Role{RoleAssistant, RoleSystem} {
msg := DocumentMessage(role, "读一下", Document{Name: "a1b2.txt"})
if _, err := buildMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) {
t.Fatalf("openai buildMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err)
}
if _, _, err := buildAnthropicMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) {
t.Fatalf("anthropic buildAnthropicMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err)
}
}
}
// TestValidateDocumentPartsAcceptsUserDocument 验证用户回合上的文档部件通过
// 校验:报错的是「挂错角色」,不是「带了文档」本身。
func TestValidateDocumentPartsAcceptsUserDocument(t *testing.T) {
t.Parallel()
msgs := []Message{
TextMessage(RoleSystem, "你是昔涟"),
DocumentMessage(RoleUser, "读一下", Document{Name: "a1b2.txt"}),
TextMessage(RoleAssistant, "好"),
}
if err := ValidateDocumentParts(msgs); err != nil {
t.Fatalf("ValidateDocumentParts = %v, want nil", err)
}
}
+17
View File
@@ -42,6 +42,10 @@ const (
// ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool
// 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。
ContentTypeToolResult ContentType = "tool_result"
// ContentTypeDocument 是文档内容片段(design/24 §2.2)。文档部件只允许
// 挂在 RoleUser 回合上,且**本期永不抵达编码器**:装配期会把它确定性
// 替换为抽取所得的文本部件或仅清单条目(design/24 §2.3)。
ContentTypeDocument ContentType = "document"
)
// Content 是消息的单个片段,可能是多模态的一部分。
@@ -66,6 +70,19 @@ type Content struct {
// IsError 把 ContentTypeToolResult 片段标记为执行失败,
// 以便原生设置提供商的 tool_result.is_error / error 标志。
IsError bool
// DocumentName 是 Type == ContentTypeDocument 时该文档的服务端存储名
// (design/24 §2.2、§3.2)。它由服务端签发,绝不是客户端传来的原始
// 文件名——原始文件名永不参与落盘路径。
DocumentName string
// DocumentMIME 是服务端**字节嗅探**所得的媒体类型(design/24 §3.2:
// 嗅探是唯一裁决),绝不是客户端的声明。
DocumentMIME string
// DocumentURL 是服务端签发的本地引用(design/24 §6.1 的文档读侧
// URL)。任意 http(s) URL 一律不接受:那会重开 F3 那种「服务端代取
// URL」的 SSRF 面。
DocumentURL string
// DocumentSize 是该文档的字节数。
DocumentSize int64
}
// Message 是单个会话回合,已跨提供商归一化。
+10
View File
@@ -128,7 +128,13 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
return buf, nil
}
// buildMessages 把归一化消息列表逐条渲染成 OpenAI 线格式,并在入口拒绝
// 挂在非用户回合上的文档部件(design/24 §2.3-③):文档本期永不抵达编码器,
// 漏网即编程错误,绝不静默丢块。
func buildMessages(msgs []Message) ([]openAIMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return nil, err
}
out := make([]openAIMessage, 0, len(msgs))
for _, m := range msgs {
wire, err := encodeOpenAIMessage(m)
@@ -196,6 +202,10 @@ func encodeToolCalls(parts []Content) []openAIToolCall {
return calls
}
// encodeContent 把归一化内容片段转成 OpenAI 的内容字段(纯文本、或
// 文本/图片片段数组)。文档片段不走这里:本期不向提供商发任何文档块
// (design/24 §2.3-②:形状不对就是 400),因此它落到 default 分支被
// 拒绝 —— fail-loud,绝不静默丢块。
func encodeContent(parts []Content) (any, error) {
if len(parts) == 0 {
return "", nil
+22 -3
View File
@@ -61,9 +61,14 @@ type historyMessage struct {
Mode string `json:"mode,omitempty"`
Mood string `json:"mood,omitempty"`
Image string `json:"image,omitempty"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),
// 形状为 {name,mime,size,chars,truncated,status}。它**只带引用与统计,
// 绝不带抽取正文** —— 正文若从这里回传,每翻一页历史都要背着它(G3 的
// 教训)。没有附件的消息省略该字段。
Document *history.DocumentRef `json:"document,omitempty"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// SenderName 是第三方消息(群聊/陌生人,design/16 §4.6)
// 给人看的发送者显示名。对主会话中主人的私聊它为
// 空,因此空意味着「标记为主人」。
@@ -402,6 +407,7 @@ func toHistoryMessage(m history.Message) historyMessage {
Mode: m.Mode,
Mood: m.Mood,
Image: m.Image,
Document: decodeHistoryDocument(m.Document),
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
@@ -418,3 +424,16 @@ func toHistoryMessage(m history.Message) historyMessage {
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50
// decodeHistoryDocument 解码已存消息的 document 列(JSON 小对象)。空列表示
// 这条消息没有附件,返回 nil 以省略该字段。列被写坏时(本服务端只会写合法
// JSON,因此那只能是外部改动或未来格式变更)**绝不静默当成没有附件** ——
// 「一方以为附件传上去了,另一方一无所知」正是 F2 要消灭的误解;这时回一个
// 标着 status=unreadable 的最小对象,让客户端看得见这条引用读不出来。
func decodeHistoryDocument(raw string) *history.DocumentRef {
ref, err := history.DecodeDocument(raw)
if err != nil {
return &history.DocumentRef{Status: history.DocumentStatusUnreadable}
}
return ref
}
@@ -0,0 +1,126 @@
package gateway
import (
"encoding/json"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是历史 DTO 的 document 对象与持久化取值的测试(design/24 §2.4、§6.1):
// 引用与统计往返、**不回传抽取正文**、坏列可见,以及 session_persist 的
// messageTextImageDocument 取值。
// TestHistoryMessageDocumentCarriesReferenceOnly 验证 document 对象只带
// 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段,
// 这里就会红。
func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocument(history.DocumentRef{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: history.DocumentStatusExtracted,
})
if err != nil {
t.Fatalf("encode: %v", err)
}
wire := toHistoryMessage(history.Message{ID: "m1", SessionID: "web", Role: "user", Content: "总结这份报告", Document: raw})
b, err := json.Marshal(wire)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
doc, ok := decoded["document"].(map[string]any)
if !ok {
t.Fatalf("document object missing in %s", b)
}
keys := make([]string, 0, len(doc))
for k := range doc {
keys = append(keys, k)
}
sort.Strings(keys)
if got := strings.Join(keys, ","); got != "chars,mime,name,size,status,truncated" {
t.Fatalf("document keys = %q, want chars,mime,name,size,status,truncated", got)
}
if doc["name"] != "0123456789abcdef0123456789abcdef.txt" || doc["status"] != history.DocumentStatusExtracted {
t.Fatalf("document = %v, want the persisted reference", doc)
}
}
// TestHistoryMessageDocumentAbsentWhenNoColumn 验证没有附件的消息省略该字段:
// 空列与「带附件」必须可区分(省略 = 没有附件)。
func TestHistoryMessageDocumentAbsentWhenNoColumn(t *testing.T) {
t.Parallel()
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "没有附件"})
b, err := json.Marshal(wire)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), `"document"`) {
t.Fatalf("empty column should omit document, got %s", b)
}
}
// TestHistoryMessageDocumentMalformedColumnIsVisible 验证列里是坏 JSON 时
// 不静默当成「没有附件」:回一个标着 status=unreadable 的最小对象,让客户端
// 看得见这条引用读不出来(design/24 §4.4 的「失败必须可见」)。
func TestHistoryMessageDocumentMalformedColumnIsVisible(t *testing.T) {
t.Parallel()
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "带附件", Document: "{not json"})
if wire.Document == nil {
t.Fatal("malformed document column was silently dropped")
}
if wire.Document.Status != history.DocumentStatusUnreadable {
t.Fatalf("status = %q, want %q", wire.Document.Status, history.DocumentStatusUnreadable)
}
}
// TestMessageTextImageDocument 验证持久化取值:文本、首个图片 URL 与首个文档
// 引用各归各列,且文档列是引用(JSON 小对象)而不是正文。
func TestMessageTextImageDocument(t *testing.T) {
t.Parallel()
doc := model.Document{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", URL: "/api/v1/documents/aaaabbbbccccdddd.txt", Size: 1234}
m := model.DocumentMessage(model.RoleUser, "读一下", doc)
m.Content = append(m.Content, model.Content{Type: model.ContentTypeImage, ImageURL: "data:image/png;base64,abc"})
text, image, raw, err := messageTextImageDocument(m)
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if text != "读一下" {
t.Fatalf("text = %q, want 读一下", text)
}
if image != "data:image/png;base64,abc" {
t.Fatalf("image = %q", image)
}
ref, err := history.DecodeDocument(raw)
if err != nil {
t.Fatalf("decode document: %v", err)
}
if ref == nil || ref.Name != doc.Name || ref.MIME != doc.MIME || ref.Size != doc.Size {
t.Fatalf("ref = %+v, want name/mime/size of %+v", ref, doc)
}
}
// TestMessageTextImageDocumentWithoutDocument 验证没有文档部件时文档列为空
// (而不是 "{}"):拼写空对象会让读取侧把它当成一条读不出来的附件。
func TestMessageTextImageDocumentWithoutDocument(t *testing.T) {
t.Parallel()
_, _, raw, err := messageTextImageDocument(model.TextMessage(model.RoleUser, "只有文本"))
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if raw != "" {
t.Fatalf("document = %q, want empty", raw)
}
}
+48 -9
View File
@@ -69,11 +69,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
if m.Role != model.RoleUser {
continue
}
text, image := messageTextImage(m)
if text == "" && image == "" {
text, image, document, err := messageTextImageDocument(m)
if err != nil {
// 文档引用编码失败(理论上不可达:引用只含字符串与整数)只丢那一列
// 并留下带来源的日志,整条消息照常落库 —— 丢一条消息远比丢一个附件
// 引用严重,而且这里绝不静默。
o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID)
}
if text == "" && image == "" && document == "" {
continue
}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: document, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
// 幂等键只标注**第一条**用户消息:一个回合至多只有一条用户消息,而把
// 同一个键盖到多条上会让它们互相冲突(唯一索引把它们判成同一条)。
if hint.ClientMsgID != "" {
@@ -122,16 +128,49 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result,
o.notifyPersisted(full)
}
// messageTextImage 返回消息内容部分拼接后的文本与首个图片
// URL,供持久化进 chat_messages 各列。
func messageTextImage(m model.Message) (text, image string) {
// messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与首个
// 文档引用(已编码为落库用的 JSON 小对象),供持久化进 chat_messages 各列。
//
// 文档那一半只落**引用与统计**(design/24 §2.4):装配期已经把抽取所得的正文
// 替换成文本部件,因此这里绝不把正文写进 document 列 —— 正文一旦落库,每翻
// 一页历史都要背着它重传(G3 的教训)。抽取统计(chars/truncated/status)属于
// 装配期与抽取器,模型层只携带引用本身;编码失败时返回错误,由调用方决定如何
// 可见地报告,而不是在这里静默当成「没有附件」。
func messageTextImageDocument(m model.Message) (text, image, document string, err error) {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
switch c.Type {
case model.ContentTypeText:
text += c.Text
} else if c.Type == model.ContentTypeImage && image == "" {
image = c.ImageURL
case model.ContentTypeImage:
if image == "" {
image = c.ImageURL
}
case model.ContentTypeDocument:
if document != "" {
continue
}
encoded, err := history.EncodeDocument(documentRefOf(c))
if err != nil {
return text, image, "", err
}
document = encoded
}
}
return text, image, document, nil
}
// documentRefOf 把文档内容片段收成落库用的引用。四个字段全部来自服务端
// (存储名、嗅探所得 MIME、本地引用、字节数),客户端的原始文件名与声明
// Content-Type 都不在其中(design/24 §2.2/§3.2)。
func documentRefOf(c model.Content) history.DocumentRef {
return history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize}
}
// messageTextImage 是 messageTextImageDocument 的窄视图:只要文本与首个图片
// URL。session_steer 的转写行配对只按这两项匹配,文档引用不参与;它也就
// 刻意不浮现文档编码的错误(那属于持久化路径的关切)。
func messageTextImage(m model.Message) (text, image string) {
text, image, _, _ = messageTextImageDocument(m)
return text, image
}