feat(gateway): persist chat sessions and messages in PostgreSQL

- store: new chat_sessions/chat_messages tables with CRUD, ILIKE message
  search, and auto-titling from the first user message
- context: optional Persister on the sliding-window store — messages are
  saved asynchronously on Add, sessions not in memory are restored from
  the database on Get
- orchestrator: ProcessInput/FinishRound now carry userID so persisted
  messages have an owner
- api: real session handlers (list/create/delete), message history and
  clear endpoints, working message search and three-format export
  (markdown/txt/json), fixing the short-ID panic in export
- main: migration, store wiring, ctxStore persister adapter

Tests: chat store handler lifecycle (fake store), context persister
save/restore, stub auth login flow updated.
This commit is contained in:
Aska committed 2026-08-20 20:34:08 +08:00
1 parent f44bf26050
commit f8196c111c
11 files changed
+787 -27

No files matched your search

+40 -1
View File
@@ -37,10 +37,12 @@ import (
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
perfpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/perf"
aiCoreCtx "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/orchestrator"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws"
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
bridge "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
@@ -79,6 +81,7 @@ func main() {
} else {
defer database.Close()
db.RunMigration(context.Background(), database, "memories", db.SplitSQL(memory.CreateTableSQL()))
db.RunMigration(context.Background(), database, "chat", db.SplitSQL(store.CreateTableSQL()))
}
// === 3. NATS (in-process) ===
@@ -161,6 +164,14 @@ func main() {
ctxStore := aiCoreCtx.NewStore(4000, 50)
logger.Infof("[context] sliding window ready (4000 tokens, 50 turns)")
// === 9.5 Chat persistence (sessions + messages) ===
var chatStore *store.ChatStore
if database != nil {
chatStore = store.NewChatStore(database)
ctxStore.SetPersister(&chatPersisterAdapter{s: chatStore})
logger.Infof("[chat] session/message persistence ready")
}
// === 10. AI-Core: Sub-session Manager ===
subMgr := subsession.NewManager(
&subsession.GeneralProvider{},
@@ -238,6 +249,9 @@ func main() {
if memSvc != nil {
gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc)
}
if chatStore != nil {
gwDeps.ChatStore = chatStore
}
gwDeps.VoiceSvc = voiceSvc
wsHub := ws.NewHub(orch)
@@ -331,6 +345,31 @@ func (a *perfAdapter) History() []map[string]any {
return result
}
// chatPersisterAdapter adapts store.ChatStore to aiCoreCtx.Persister.
type chatPersisterAdapter struct{ s *store.ChatStore }
func (a *chatPersisterAdapter) SaveMessage(sessionID, userID string, msg model.Message) {
if userID == "" {
userID = "unknown"
}
if err := a.s.AddMessage(context.Background(), sessionID, userID, msg.Role, msg.Content); err != nil {
logger.Infof("[chat] persist message failed: %v", err)
}
}
func (a *chatPersisterAdapter) LoadRecent(sessionID string, limit int) []model.Message {
msgs, err := a.s.ListMessages(context.Background(), sessionID, limit)
if err != nil {
logger.Infof("[chat] load history failed: %v", err)
return nil
}
out := make([]model.Message, len(msgs))
for i, m := range msgs {
out[i] = model.Message{SessionID: m.SessionID, Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
}
return out
}
// toolExecAdapter adapts the plugin registry to orchestrator.ToolExecutor.
type toolExecAdapter struct{ reg *plugin.Registry }
@@ -389,7 +428,7 @@ func loadAdaptersFromConfig(router *bridge.Router, cfg *bridge.BridgeConfig, orc
router.SetMessageHandler(func(msg *bridge.UnifiedMessage) {
sessionID := "bridge-" + msg.Source
tokenCh, _, err := orch.ProcessInput(context.Background(), sessionID, msg.Content)
tokenCh, _, err := orch.ProcessInput(context.Background(), sessionID, msg.SenderID, msg.Content)
if err != nil { return }
var fc string
for t := range tokenCh { fc += t.Content }