feat: IM式跨端实时同步——message_persisted 广播 + 前端按id去重追加
- 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:
1 parent
bcc53d68f3
commit
1bf60c1ae4
9 files changed
+260
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user