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

+47 -1
View File
@@ -10,12 +10,23 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
)
// Persister saves conversation messages durably and can restore them
// (e.g. from PostgreSQL) when a session is not in memory.
type Persister interface {
// SaveMessage persists one message. Called synchronously by Add —
// implementations should be fast or dispatch async work themselves.
SaveMessage(sessionID, userID string, msg model.Message)
// LoadRecent returns the most recent messages of a session, ascending.
LoadRecent(sessionID string, limit int) []model.Message
}
// Store holds conversation history with a sliding window.
type Store struct {
mu sync.RWMutex
sessions map[string]*SessionHistory
maxTokens int // Maximum tokens to include in context
maxTurns int // Maximum message turns to keep
persister Persister
}
// SessionHistory holds messages for a single session.
@@ -39,14 +50,31 @@ func NewStore(maxTokens, maxTurns int) *Store {
}
}
// SetPersister injects durable persistence for conversation messages.
func (s *Store) SetPersister(p Persister) {
s.mu.Lock()
s.persister = p
s.mu.Unlock()
}
// Add appends a message to the session history.
func (s *Store) Add(sessionID string, msg model.Message) {
s.add(sessionID, "", msg)
}
// AddWithUser appends a message and records the owning user for persistence.
func (s *Store) AddWithUser(sessionID, userID string, msg model.Message) {
s.add(sessionID, userID, msg)
}
func (s *Store) add(sessionID, userID string, msg model.Message) {
s.mu.Lock()
sh, ok := s.sessions[sessionID]
if !ok {
sh = &SessionHistory{}
s.sessions[sessionID] = sh
}
persister := s.persister
s.mu.Unlock()
sh.mu.Lock()
@@ -56,15 +84,33 @@ func (s *Store) Add(sessionID string, msg model.Message) {
sh.Messages = sh.Messages[len(sh.Messages)-s.maxTurns:]
}
sh.mu.Unlock()
// Persist asynchronously — never block the conversation on the database.
if persister != nil && (msg.Role == "user" || msg.Role == "assistant") {
go persister.SaveMessage(sessionID, userID, msg)
}
}
// Get retrieves recent messages for a session, trimmed to fit token budget.
// Sessions missing from memory are restored from the persister if available.
func (s *Store) Get(sessionID string) []model.Message {
s.mu.RLock()
sh, ok := s.sessions[sessionID]
persister := s.persister
s.mu.RUnlock()
if !ok {
return nil
if persister == nil {
return nil
}
loaded := persister.LoadRecent(sessionID, s.maxTurns)
if len(loaded) == 0 {
return nil
}
sh = &SessionHistory{Messages: loaded}
s.mu.Lock()
s.sessions[sessionID] = sh
s.mu.Unlock()
}
sh.mu.RLock()