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") 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 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有 // HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有
// 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际 // 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际
// 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里 // 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里
@@ -228,8 +152,8 @@ type rowScanner interface {
} }
// NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在 // NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在
// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,见 // 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,前滚
// client_msg_id.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与 // 见 document.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。 // memory 和 session 包一致。db 必须可用;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 {
@@ -237,8 +161,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
} }
log = logger.WithSource(discardLogger(log), "history") log = logger.WithSource(discardLogger(log), "history")
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后 // 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
// ensureClientMsgIDColumn 把早于该列建出的既有表前滚(幂等),最后才建索引 // ensureMessageColumns 把早于这些列(client_msg_id 与 document)建出的既有表
// —— 部分唯一索引引用 client_msg_id,列还不存在时建它必然失败。 // 前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不存在时
// 建它必然失败。
for _, ddl := range []string{ for _, ddl := range []string{
createSessionsTable, createSessionsTable,
createMessagesTable, 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) 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 return nil, err
} }
for _, ddl := range []string{ 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) s.ensureTitle(ctx, msg.SessionID, msg.Content)
} }
query := `INSERT INTO chat_messages 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) (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if msg.ClientMsgID != "" { if msg.ClientMsgID != "" {
query += ` ON CONFLICT DO NOTHING` query += ` ON CONFLICT DO NOTHING`
} }
res, err := s.db.ExecContext(ctx, query, res, err := s.db.ExecContext(ctx, query,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image,
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.ClientMsgID, msg.CreatedAt) msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID,
msg.PersonaID, msg.ClientMsgID, msg.CreatedAt)
if err != nil { if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err) 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 完全一致)。 // **唯一**一份列清单(位置顺序必须与 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。 // scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。
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
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 return Message{}, err
} }
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID 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, mode TEXT,
mood TEXT, mood TEXT,
image TEXT, image TEXT,
document TEXT NOT NULL DEFAULT '',
thinking TEXT, thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web', platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '', 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 内容块形式表示的消息。所有 // 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic // RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息 // 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。 // 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的
// 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不
// 抵达编码器,漏网即编程错误,绝不静默丢块。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) { func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return "", nil, err
}
var sys strings.Builder var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs)) out := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs { for _, m := range msgs {
@@ -97,7 +102,10 @@ func encodeAnthropicToolResult(m Message) ([]any, error) {
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。 // encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result // 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。 // 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走
// 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document
// 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②,
// fail-loud 绝不静默丢块)。
func encodeAnthropicContent(parts []Content) ([]any, error) { func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts)) blocks := make([]any, 0, len(parts))
for _, p := range 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 // ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool
// 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。 // 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。
ContentTypeToolResult ContentType = "tool_result" ContentTypeToolResult ContentType = "tool_result"
// ContentTypeDocument 是文档内容片段(design/24 §2.2)。文档部件只允许
// 挂在 RoleUser 回合上,且**本期永不抵达编码器**:装配期会把它确定性
// 替换为抽取所得的文本部件或仅清单条目(design/24 §2.3)。
ContentTypeDocument ContentType = "document"
) )
// Content 是消息的单个片段,可能是多模态的一部分。 // Content 是消息的单个片段,可能是多模态的一部分。
@@ -66,6 +70,19 @@ type Content struct {
// IsError 把 ContentTypeToolResult 片段标记为执行失败, // IsError 把 ContentTypeToolResult 片段标记为执行失败,
// 以便原生设置提供商的 tool_result.is_error / error 标志。 // 以便原生设置提供商的 tool_result.is_error / error 标志。
IsError bool 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 是单个会话回合,已跨提供商归一化。 // Message 是单个会话回合,已跨提供商归一化。
+10
View File
@@ -128,7 +128,13 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
return buf, nil return buf, nil
} }
// buildMessages 把归一化消息列表逐条渲染成 OpenAI 线格式,并在入口拒绝
// 挂在非用户回合上的文档部件(design/24 §2.3-③):文档本期永不抵达编码器,
// 漏网即编程错误,绝不静默丢块。
func buildMessages(msgs []Message) ([]openAIMessage, error) { func buildMessages(msgs []Message) ([]openAIMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return nil, err
}
out := make([]openAIMessage, 0, len(msgs)) out := make([]openAIMessage, 0, len(msgs))
for _, m := range msgs { for _, m := range msgs {
wire, err := encodeOpenAIMessage(m) wire, err := encodeOpenAIMessage(m)
@@ -196,6 +202,10 @@ func encodeToolCalls(parts []Content) []openAIToolCall {
return calls return calls
} }
// encodeContent 把归一化内容片段转成 OpenAI 的内容字段(纯文本、或
// 文本/图片片段数组)。文档片段不走这里:本期不向提供商发任何文档块
// (design/24 §2.3-②:形状不对就是 400),因此它落到 default 分支被
// 拒绝 —— fail-loud,绝不静默丢块。
func encodeContent(parts []Content) (any, error) { func encodeContent(parts []Content) (any, error) {
if len(parts) == 0 { if len(parts) == 0 {
return "", nil return "", nil
+22 -3
View File
@@ -61,9 +61,14 @@ type historyMessage struct {
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Mood string `json:"mood,omitempty"` Mood string `json:"mood,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
Thinking string `json:"thinking,omitempty"` // Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),
Platform string `json:"platform,omitempty"` // 形状为 {name,mime,size,chars,truncated,status}。它**只带引用与统计,
DeviceID string `json:"deviceId,omitempty"` // 绝不带抽取正文** —— 正文若从这里回传,每翻一页历史都要背着它(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) // SenderName 是第三方消息(群聊/陌生人,design/16 §4.6)
// 给人看的发送者显示名。对主会话中主人的私聊它为 // 给人看的发送者显示名。对主会话中主人的私聊它为
// 空,因此空意味着「标记为主人」。 // 空,因此空意味着「标记为主人」。
@@ -402,6 +407,7 @@ func toHistoryMessage(m history.Message) historyMessage {
Mode: m.Mode, Mode: m.Mode,
Mood: m.Mood, Mood: m.Mood,
Image: m.Image, Image: m.Image,
Document: decodeHistoryDocument(m.Document),
Thinking: m.Thinking, Thinking: m.Thinking,
Platform: m.Platform, Platform: m.Platform,
DeviceID: m.DeviceID, DeviceID: m.DeviceID,
@@ -418,3 +424,16 @@ func toHistoryMessage(m history.Message) historyMessage {
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。 // historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50 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 { if m.Role != model.RoleUser {
continue continue
} }
text, image := messageTextImage(m) text, image, document, err := messageTextImageDocument(m)
if text == "" && image == "" { if err != nil {
// 文档引用编码失败(理论上不可达:引用只含字符串与整数)只丢那一列
// 并留下带来源的日志,整条消息照常落库 —— 丢一条消息远比丢一个附件
// 引用严重,而且这里绝不静默。
o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID)
}
if text == "" && image == "" && document == "" {
continue 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 != "" { if hint.ClientMsgID != "" {
@@ -122,16 +128,49 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result,
o.notifyPersisted(full) o.notifyPersisted(full)
} }
// messageTextImage 返回消息内容部分拼接后的文本与首个图片 // messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与首个
// URL,供持久化进 chat_messages 各列。 // 文档引用(已编码为落库用的 JSON 小对象),供持久化进 chat_messages 各列。
func messageTextImage(m model.Message) (text, image string) { //
// 文档那一半只落**引用与统计**(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 { for _, c := range m.Content {
if c.Type == model.ContentTypeText { switch c.Type {
case model.ContentTypeText:
text += c.Text text += c.Text
} else if c.Type == model.ContentTypeImage && image == "" { case model.ContentTypeImage:
image = c.ImageURL 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 return text, image
} }