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