feat: IM式跨端实时同步——message_persisted 广播 + 前端按id去重追加
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- history.Store 新增 AppendFull(返回含 id/createdAt 的完整消息),Append 签名不变
- session_orchestrator 加可空 OnPersisted;persistUserMessages/persistAssistantReply 落库后回调广播
- server.go newOrchestrator 接 OnPersisted → hub.broadcast message_persisted(thinking 剥除)
- 前端 ws.ts 加 message_persisted case(仅 activeSessionId 匹配才处理);appendOrUpdateMessage 升级为 id 精确upsert+内容/时间归一
- 实测:双 WS,A stream 发消息,B 免刷新收到 user+assistant 各1条(count=2 PASS)
- 红线: 身份仍靠 actor 元数据,不靠提示词;用户/助手消息、本端/异端统一路径
This commit is contained in:
Aska committed 2026-09-07 04:16:29 +08:00
1 parent bcc53d68f3
commit 1bf60c1ae4
9 files changed
+260 -38

No files matched your search

+23 -4
View File
@@ -87,6 +87,9 @@ type SessionMeta struct {
type HistoryStore interface {
// Append persists msg, assigning an id and timestamp when the caller omitted them.
Append(ctx context.Context, msg Message) error
// AppendFull persists msg like Append but returns the complete stored message
// (with the assigned id/timestamp) so a caller can broadcast it for cross-end sync.
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).
@@ -144,12 +147,28 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
}
// Append persists msg, assigning a fresh id and a now-ms CreatedAt when the caller left
// either empty/zero. It returns the error wrapped with the operation name.
// either empty/zero. It returns the error wrapped with the operation name. Callers that
// need the generated id/timestamp back should use AppendFull instead.
func (s *Store) Append(ctx context.Context, msg Message) error {
_, err := s.insert(ctx, msg)
return err
}
// AppendFull persists msg like Append but returns the complete stored message — the id
// and CreatedAt the store assigned when the caller left them empty/zero — so a caller can
// broadcast it for cross-end realtime sync (design/16 §4.2) without a second read.
func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) {
return s.insert(ctx, msg)
}
// insert is the shared Append/AppendFull body. It assigns a fresh id and a now-ms
// CreatedAt when the caller left either empty/zero, then inserts the row and returns the
// resulting message.
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.ID == "" {
id, err := newID()
if err != nil {
return fmt.Errorf("history: append id: %w", err)
return Message{}, fmt.Errorf("history: append id: %w", err)
}
msg.ID = id
}
@@ -162,9 +181,9 @@ func (s *Store) Append(ctx context.Context, msg Message) error {
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
msg.Image, msg.Thinking, msg.CreatedAt)
if err != nil {
return 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)
}
return nil
return msg, nil
}
// List returns up to limit messages for sessionID, newest first. When before is nonzero
+38
View File
@@ -76,6 +76,44 @@ func TestAppendAndListNewestFirst(t *testing.T) {
}
}
func TestAppendFullReturnsID(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.ID == "" {
t.Fatal("AppendFull did not assign an id")
}
if full.CreatedAt == 0 {
t.Fatal("AppendFull did not assign a created_at")
}
if full.SessionID != MainSessionID || full.Role != RoleUser || full.Content != "hi" {
t.Fatalf("AppendFull returned %+v, want the stored message unchanged plus id/created_at", full)
}
// The message is actually persisted under the returned id.
msgs, err := st.List(ctx, MainSessionID, 0, 50)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 || msgs[0].ID != full.ID {
t.Fatalf("list = %+v, want exactly the persisted message id %q", msgs, full.ID)
}
// A caller-supplied id/timestamp is preserved, not regenerated.
preset, err := st.AppendFull(ctx, Message{ID: "preset-id", SessionID: MainSessionID, Role: RoleAssistant, Content: "ok", CreatedAt: 42})
if err != nil {
t.Fatalf("append full preset: %v", err)
}
if preset.ID != "preset-id" || preset.CreatedAt != 42 {
t.Fatalf("AppendFull did not preserve preset id/created_at: %+v", preset)
}
}
func TestListPaginationBefore(t *testing.T) {
t.Parallel()
st := newStore(t)