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

+82 -8
View File
@@ -12,6 +12,7 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
)
// --- Interfaces for injected services ---
@@ -51,7 +52,7 @@ type VoiceService interface {
// OrchestratorPipeline handles the main dialogue flow.
type OrchestratorPipeline interface {
ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
ProcessInput(ctx context.Context, sessionID, userID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
}
// WebSocketHub manages WS connections.
@@ -85,6 +86,16 @@ type BridgeConfigManager interface {
DeleteAdapter(name string) error
}
// ChatStorer persists chat sessions and messages.
type ChatStorer interface {
CreateSession(ctx context.Context, id, userID, title string) (*store.Session, error)
ListSessions(ctx context.Context, userID string) ([]store.Session, error)
DeleteSession(ctx context.Context, id, userID string) error
ListMessages(ctx context.Context, sessionID string, limit int) ([]store.Message, error)
ClearMessages(ctx context.Context, sessionID string) error
SearchMessages(ctx context.Context, userID, query string, limit int) ([]store.Message, error)
}
// Dependencies holds all injectable services.
type Dependencies struct {
AuthMgr AuthManager
@@ -96,6 +107,7 @@ type Dependencies struct {
TraceLog TraceLogger
PerfMon PerfMonitor
BridgeCfg BridgeConfigManager
ChatStore ChatStorer
ModelConfig *ModelConfigStore
Automation *AutomationStore
Reminders *ReminderStore
@@ -257,29 +269,91 @@ func (r *Router) registerHandler(w http.ResponseWriter, req *http.Request) {
// --- Sessions ---
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.ChatStore == nil {
writeJSON(w, 503, M{"error": "chat store unavailable"})
return
}
uid := r.userID(req)
switch req.Method {
case "GET":
writeJSON(w, 200, M{"sessions": []any{}})
sessions, err := r.deps.ChatStore.ListSessions(req.Context(), uid)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"sessions": sessions})
case "POST":
writeJSON(w, 201, M{"session_id": randID()})
var body struct {
Title string `json:"title"`
}
json.NewDecoder(req.Body).Decode(&body)
sess, err := r.deps.ChatStore.CreateSession(req.Context(), randID(), uid, body.Title)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 201, M{"session_id": sess.ID, "title": sess.Title})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
// Handle export
if strings.HasSuffix(id, "/export") {
id = strings.TrimSuffix(id, "/export")
if r.deps.ChatStore == nil {
writeJSON(w, 503, M{"error": "chat store unavailable"})
return
}
rest := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
// Sub-resources: /sessions/{id}/export and /sessions/{id}/messages
if strings.HasSuffix(rest, "/export") {
r.sessionExportHandler(w, req)
return
}
if strings.HasSuffix(rest, "/messages") {
r.sessionMessagesHandler(w, req)
return
}
id := rest
switch req.Method {
case "DELETE":
if err := r.deps.ChatStore.DeleteSession(req.Context(), id, r.userID(req)); err != nil {
writeJSON(w, 404, M{"error": err.Error()})
return
}
writeJSON(w, 204, nil)
case "GET":
writeJSON(w, 200, M{"session_id": id})
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 100)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"session_id": id, "messages": messages})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
// sessionMessagesHandler serves GET (recent history) and DELETE (clear).
func (r *Router) sessionMessagesHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/messages")
switch req.Method {
case "GET":
limit := 0
fmt.Sscanf(req.URL.Query().Get("limit"), "%d", &limit)
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, limit)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"messages": messages})
case "DELETE":
if err := r.deps.ChatStore.ClearMessages(req.Context(), id); err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 204, nil)
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}