feat: IM平台列 + deviceID标识 + 会话持久化完善
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted)
- agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列)
- design/16 §4.4(平台) + §4.5(deviceID) 已入档
- 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿
- 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
This commit is contained in:
Aska committed 2026-09-07 04:51:41 +08:00
1 parent 1bf60c1ae4
commit 37e98ec584
26 files changed
+828 -116

No files matched your search

+45 -13
View File
@@ -37,6 +37,11 @@ const (
// SessionMain and SessionSub classify a persistent session.
SessionMain = "main"
SessionSub = "sub"
// PlatformWeb is the source-platform tag for messages that originate on the Web
// main session (design/16 §4.4). It is both the column default and the value
// assigned when a caller leaves Message.Platform empty.
PlatformWeb = "web"
)
// MainSessionID is the fixed id of the one main session (design/16 §2.1). Sub session
@@ -71,6 +76,17 @@ type Message struct {
Mood string
Image string
Thinking string
// Platform tags the source platform of a message (design/16 §4.4). The Web main
// session is shared across platforms: messages that originate on the Web carry
// "web", while IM-owner-private messages carry the channel platform (e.g.
// "telegram", "qq-obv11", "weixin-ilink"). An empty Platform defaults to "web" so
// legacy/explicitly-Web writes stay on the Web lane without a separate assignment.
Platform string
// DeviceID is a short, client-produced identifier for the concrete device/app
// that sent the message (design/16 §4.5) — e.g. "web:chrome:a1b2c3" for a
// browser, "weixin-ilink:conf0:wx_uid_123" for IM. It complements Platform: two
// clients on the same platform share Platform but differ in DeviceID.
DeviceID string
CreatedAt int64
}
@@ -92,8 +108,12 @@ type HistoryStore interface {
AppendFull(ctx context.Context, msg Message) (Message, error)
// List returns up to limit messages for sessionID, newest first. before is a
// millisecond timestamp cursor: when nonzero, only messages older than before are
// returned (inclusive-free, matching the "before" bound on the frontend).
List(ctx context.Context, sessionID string, before int64, limit int) ([]Message, error)
// returned (inclusive-free, matching the "before" bound on the frontend). platform
// is an optional source-platform filter (design/16 §4.4): when non-empty only
// messages carrying that platform are returned; when empty all platforms are
// returned (the Web client requests the full main session, an IM adapter asks for
// its own platform only).
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error)
// Recent returns the most recent n messages for sessionID in oldest-first order,
// for WS replay / context tail usage.
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
@@ -175,11 +195,14 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.CreatedAt == 0 {
msg.CreatedAt = time.Now().UnixMilli()
}
if msg.Platform == "" {
msg.Platform = PlatformWeb
}
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, thinking, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
msg.Image, msg.Thinking, msg.CreatedAt)
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.CreatedAt)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
@@ -188,17 +211,24 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
// List returns up to limit messages for sessionID, newest first. When before is nonzero
// it bounds the query to messages strictly older than before, so a client can page upward
// by passing the oldest message's created_at from the previous page. The returned slice
// is non-nil. limit is clamped to [1, maxPageLimit].
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int) ([]Message, error) {
// by passing the oldest message's created_at from the previous page. When platform is
// non-empty it further restricts the page to messages carrying that source platform
// (design/16 §4.4: the Web client omits platform for the full main session, an IM
// adapter passes its own platform to see only its lane). The returned slice is non-nil.
// limit is clamped to [1, maxPageLimit].
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, created_at
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, created_at
FROM chat_messages WHERE session_id = ?`
args := []any{sessionID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
if platform != "" {
query += ` AND platform = ?`
args = append(args, platform)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
@@ -228,7 +258,7 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
if n < 1 {
n = defaultPageLimit
}
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, created_at
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, created_at
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
if err != nil {
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
@@ -342,11 +372,11 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
// scanMessage reads one chat_messages row. message columns are scanned positionally.
func scanMessage(sc rowScanner) (Message, error) {
var m Message
var mode, mood, image, thinking string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &image, &thinking, &m.CreatedAt); err != nil {
var mode, mood, image, thinking, platform, deviceID string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &image, &thinking, &platform, &deviceID, &m.CreatedAt); err != nil {
return Message{}, err
}
m.Mode, m.Mood, m.Image, m.Thinking = mode, mood, image, thinking
m.Mode, m.Mood, m.Image, m.Thinking, m.Platform, m.DeviceID = mode, mood, image, thinking, platform, deviceID
return m, nil
}
@@ -401,6 +431,8 @@ CREATE TABLE IF NOT EXISTS chat_messages (
mood TEXT,
image TEXT,
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`