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
)`
+134 -7
View File
@@ -51,7 +51,7 @@ func TestAppendAndListNewestFirst(t *testing.T) {
}
}
msgs, err := st.List(ctx, MainSessionID, 0, 50)
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -67,7 +67,7 @@ func TestAppendAndListNewestFirst(t *testing.T) {
if err := st.Append(ctx, empty); err != nil {
t.Fatalf("append no ts: %v", err)
}
msgs, err = st.List(ctx, MainSessionID, 0, 50)
msgs, err = st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list after append: %v", err)
}
@@ -96,7 +96,7 @@ func TestAppendFullReturnsID(t *testing.T) {
}
// The message is actually persisted under the returned id.
msgs, err := st.List(ctx, MainSessionID, 0, 50)
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -114,6 +114,133 @@ func TestAppendFullReturnsID(t *testing.T) {
}
}
func TestAppendFullListPlatformRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// An IM owner-private message carries its channel platform through the round
// trip: AppendFull → List.
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "从微信来", Platform: "weixin-ilink"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.Platform != "weixin-ilink" {
t.Fatalf("AppendFull platform = %q, want weixin-ilink", full.Platform)
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].Platform != "weixin-ilink" || msgs[0].Content != "从微信来" {
t.Fatalf("list round trip = %+v, want platform weixin-ilink", msgs)
}
// A message without an explicit platform defaults to the web lane.
web, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
if err != nil {
t.Fatalf("append web default: %v", err)
}
if web.Platform != PlatformWeb {
t.Fatalf("default platform = %q, want web", web.Platform)
}
}
func TestAppendFullListDeviceIDRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// A Web message tagged with a browser deviceID survives the AppendFull → List
// round trip (design/16 §4.5).
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "来自浏览器", DeviceID: "web:chrome:a1b2c3"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.DeviceID != "web:chrome:a1b2c3" {
t.Fatalf("AppendFull DeviceID = %q, want web:chrome:a1b2c3", full.DeviceID)
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].DeviceID != "web:chrome:a1b2c3" || msgs[0].Content != "来自浏览器" {
t.Fatalf("list round trip = %+v, want DeviceID web:chrome:a1b2c3", msgs)
}
// Recent (WS replay / context tail) reads the same device_id column.
recent, err := st.Recent(ctx, MainSessionID, 5)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(recent) != 1 || recent[0].DeviceID != "web:chrome:a1b2c3" {
t.Fatalf("recent = %+v, want DeviceID web:chrome:a1b2c3", recent)
}
// A message without an explicit deviceID persists the column default ('').
om, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"})
if err != nil {
t.Fatalf("append empty device: %v", err)
}
if om.DeviceID != "" {
t.Fatalf("empty DeviceID = %q, want empty", om.DeviceID)
}
}
func TestListPlatformFilter(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// Seed across the web lane (explicit + default) and one IM lane.
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "web1", Platform: PlatformWeb, CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000},
{SessionID: MainSessionID, Role: RoleUser, Content: "web2", CreatedAt: 3000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// No platform filter → all three (the empty-platform message defaulted to web).
all, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list all: %v", err)
}
if len(all) != 3 {
t.Fatalf("all = %d, want 3", len(all))
}
// Filter to the IM lane → only its message.
tg, err := st.List(ctx, MainSessionID, 0, 50, "telegram")
if err != nil {
t.Fatalf("list telegram: %v", err)
}
if len(tg) != 1 || tg[0].Content != "tg" {
t.Fatalf("telegram page = %+v, want [tg]", tg)
}
// Filter to the web lane → web1 + web2 (the one left without an explicit
// platform defaulted to web on insert).
web, err := st.List(ctx, MainSessionID, 0, 50, PlatformWeb)
if err != nil {
t.Fatalf("list web: %v", err)
}
if len(web) != 2 {
t.Fatalf("web page = %d, want 2 (%+v)", len(web), web)
}
// The filter composes with the before cursor.
older, err := st.List(ctx, MainSessionID, 2000, 50, PlatformWeb)
if err != nil {
t.Fatalf("list web before: %v", err)
}
if len(older) != 1 || older[0].Content != "web1" {
t.Fatalf("web before page = %+v, want [web1]", older)
}
}
func TestListPaginationBefore(t *testing.T) {
t.Parallel()
st := newStore(t)
@@ -126,7 +253,7 @@ func TestListPaginationBefore(t *testing.T) {
}
// First page: newest 2.
page, err := st.List(ctx, MainSessionID, 0, 2)
page, err := st.List(ctx, MainSessionID, 0, 2, "")
if err != nil {
t.Fatalf("first page: %v", err)
}
@@ -134,7 +261,7 @@ func TestListPaginationBefore(t *testing.T) {
t.Fatalf("first page = %+v, want [6000 5000]", page)
}
// Cursor on the page's oldest (5000) → older page [4000 3000].
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2)
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2, "")
if err != nil {
t.Fatalf("next page: %v", err)
}
@@ -142,7 +269,7 @@ func TestListPaginationBefore(t *testing.T) {
t.Fatalf("next page = %+v, want [4000 3000]", next)
}
// Past the start → empty.
tail, err := st.List(ctx, MainSessionID, 1000, 50)
tail, err := st.List(ctx, MainSessionID, 1000, 50, "")
if err != nil {
t.Fatalf("tail: %v", err)
}
@@ -220,7 +347,7 @@ func TestDeleteSession(t *testing.T) {
if err := st.DeleteSession(ctx, sub); err != nil {
t.Fatalf("delete sub: %v", err)
}
msgs, err := st.List(ctx, sub, 0, 50)
msgs, err := st.List(ctx, sub, 0, 50, "")
if err != nil {
t.Fatalf("list after delete: %v", err)
}