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

+50 -10
View File
@@ -166,20 +166,60 @@ func (r *Router) notificationHandler(w http.ResponseWriter, req *http.Request) {
func (r *Router) messageSearchHandler(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query().Get("q")
uid := req.Header.Get("X-User-ID")
uid := r.userID(req)
if q == "" { writeJSON(w, 400, M{"error": "missing query"}); return }
// Uses session store DB — returns matching messages
writeJSON(w, 200, M{"messages": []any{}, "query": q, "user_id": uid, "total": 0})
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.SearchMessages(req.Context(), uid, q, 20)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"messages": messages, "query": q, "total": len(messages)})
}
func (r *Router) sessionExportHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
id = strings.TrimSuffix(id, "/export")
// Export session as markdown
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", id[:8]))
w.WriteHeader(200)
fmt.Fprintf(w, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", id[:8], time.Now().Format(time.RFC3339))
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/export")
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 200)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
short := id
if len(short) > 8 { short = short[:8] }
format := req.URL.Query().Get("format")
exportedAt := time.Now().Format(time.RFC3339)
switch format {
case "json":
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.json", short))
writeJSON(w, 200, M{"session_id": id, "exported_at": exportedAt, "messages": messages})
case "txt":
var sb strings.Builder
fmt.Fprintf(&sb, "Cyrene Chat - Session %s (exported %s)\n\n", short, exportedAt)
for _, m := range messages {
fmt.Fprintf(&sb, "[%s] %s: %s\n\n", m.CreatedAt.Format("2006-01-02 15:04"), m.Role, m.Content)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.txt", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
default: // markdown
var sb strings.Builder
fmt.Fprintf(&sb, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", short, exportedAt)
for _, m := range messages {
who := "用户"
if m.Role == "assistant" { who = "昔涟" }
fmt.Fprintf(&sb, "**%s**(%s):\n\n%s\n\n---\n\n", who, m.CreatedAt.Format("2006-01-02 15:04"), m.Content)
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
}
}
func (r *Router) imageAnalyzeHandler(w http.ResponseWriter, req *http.Request) {