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

+31 -2
View File
@@ -37,6 +37,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
@@ -102,6 +103,19 @@ type MemoryStore interface {
List(ctx context.Context) ([]memory.MemoryItem, error)
}
// HistoryStore is the persistent chat-history store (design/16). *runtime/history.Store
// is the production implementation. When nil the session/message endpoints degrade: list
// sessions falls back to the channel-session view, and the create/messages/delete
// endpoints answer with a clear 501.
type HistoryStore interface {
Append(ctx context.Context, msg history.Message) error
List(ctx context.Context, sessionID string, before int64, limit int) ([]history.Message, error)
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
DeleteSession(ctx context.Context, sessionID string) error
NewSubSession(ctx context.Context, ownerID string) (string, error)
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
}
// IoTStore lists IoT devices. *iot.Manager is the production implementation.
type IoTStore interface {
List(ctx context.Context) ([]iot.Device, error)
@@ -179,6 +193,9 @@ type Deps struct {
Personas PersonaStore
// Memories lists memory entries. Optional.
Memories MemoryStore
// History is the persistent chat-history store (design/16). Optional; when nil the
// session/message endpoints degrade (list falls back to channel sessions, others 501).
History HistoryStore
// IoT lists IoT devices. Optional.
IoT IoTStore
// Scheduler audits sub-agent context for the subagent_ask WS route.
@@ -229,6 +246,7 @@ type Server struct {
sessions SessionStore
personas PersonaStore
memories MemoryStore
history HistoryStore
iot IoTStore
auditor SubagentAuditor
taskOutputs TaskOutputStore
@@ -270,6 +288,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
sessions: deps.Session,
personas: deps.Personas,
memories: deps.Memories,
history: deps.History,
iot: deps.IoT,
auditor: deps.Scheduler,
taskOutputs: deps.Tasks,
@@ -305,6 +324,15 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/sessions", readAuth(http.HandlerFunc(s.handleListSessions)))
mux.Handle("GET /api/v1/personas", readAuth(http.HandlerFunc(s.handleListPersonas)))
// Session/model reads and writes are the owner's private history; owner/admin only
// (chat uses the same roles). Session metadata in GET /sessions stays readAuth for
// any authenticated role, but the message content (and thinking) is owner/admin-only
// per design/16 §6. Creating and deleting a sub session mutate persistent state.
historyAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
mux.Handle("GET /api/v1/sessions/{id}/messages", historyAuth(http.HandlerFunc(s.handleListMessages)))
mux.Handle("POST /api/v1/sessions", historyAuth(http.HandlerFunc(s.handleCreateSubSession)))
mux.Handle("DELETE /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleDeleteSession)))
// Realtime.
mux.HandleFunc("/ws/chat", s.handleWS)
@@ -425,9 +453,10 @@ func newOrchestrator(s *Server) *Orchestrator {
return s.hub.runTurn(ctx, sessionID, messages, env)
},
Emit: func(sessionID, typ string, data map[string]any) {
s.hub.broadcast(wsEvent{Type: typ, Data: data})
s.hub.broadcast(wsEvent{Type: typ, SessionID: sessionID, Data: data})
},
Log: s.log,
Log: s.log,
History: s.history,
})
}