feat(history): 会话消息持久化 + 主/次级会话(design/16)

- runtime/history: Store + HistoryStore 接口(Append/List 游标分页/Recent/DeleteSession/NewSubSession/ListSessions),主会话固定 web、次级 web:<uuid>
- db 迁移 0003: chat_sessions + chat_messages + idx_chat_msgs_session_created(事务幂等)
- gateway: GET/POST /sessions、GET /sessions/{id}/messages 热分页、DELETE /sessions/{id}(主不可删 403);Deps.History 可空注入
- 编排层落库: session_persist.go(submitInternal 写用户消息、completeTurn 写助手消息,拒绝的消息不落库)
- WS: wsEvent 加 sessionId 定向 + history 回放帧(握手上行后回放主会话最新 50 条,thinking 剥离)
- wire.go 装配 History 到 gateway.Deps.History
- 端到端实测: POST /sessions 建次级 201 / GET /sessions 列表 / POST /chat 后 /messages 读到 user+assistant / DELETE 次级成功 / DELETE 主会话 403
This commit is contained in:
Aska committed 2026-09-07 03:52:43 +08:00
1 parent cc3eecacac
commit 48421923f6
15 files changed
+1305 -18

No files matched your search

+20 -15
View File
@@ -17,6 +17,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -41,10 +42,11 @@ const (
// used by the streaming path so a client can correlate a burst of streamed
// events to one assistant turn and keep them in order.
type wsEvent struct {
Type string `json:"type"`
TurnID string `json:"turnId,omitempty"`
Seq int `json:"seq,omitempty"`
Data any `json:"data,omitempty"`
Type string `json:"type"`
TurnID string `json:"turnId,omitempty"`
Seq int `json:"seq,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Data any `json:"data,omitempty"`
}
// wsInbound is the client→server frame. Data carries the per-type payload.
@@ -68,15 +70,16 @@ type wsConn struct {
// hub owns the connection set and relays bus events to every client.
type hub struct {
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
voice VoiceService
streaming bool
orchestrator *Orchestrator
history HistoryStore
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
@@ -96,6 +99,7 @@ func newHub(s *Server) *hub {
taskOutputs: s.taskOutputs,
voice: s.voice,
streaming: s.streaming,
history: s.history,
log: s.log,
origins: origins,
allowAll: allowAll,
@@ -222,7 +226,7 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
_ = json.Unmarshal(b, &req)
}
if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") {
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), SessionID: req.SessionID, Data: map[string]any{"stopReason": "idle"}})
return
}
if req.SessionID == "" {
@@ -248,19 +252,19 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
turnID := newTurnID()
if h.runner == nil || len(msgs) == 0 {
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}})
return &loop.Result{StopReason: "idle"}, nil
}
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs)
if err != nil {
h.log.Error("chat run", "err", err, "session", sessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return nil, err
}
if !h.streaming {
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"delta": res.Text}})
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"delta": res.Text}})
}
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
return res, nil
}
@@ -330,6 +334,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
claims: claims,
}
s.hub.register(c)
s.hub.replayHistory(c, history.MainSessionID)
go c.writePump()
go c.readPump()
}