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:
1 parent
f44bf26050
commit
f8196c111c
11 files changed
+787
-27
No files matched your search
@@ -37,10 +37,12 @@ import (
|
|||||||
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
|
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
|
||||||
perfpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/perf"
|
perfpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/perf"
|
||||||
aiCoreCtx "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context"
|
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/orchestrator"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
"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/ai-core/subsession"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
|
"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/gateway/ws"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
|
||||||
bridge "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
bridge "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
||||||
@@ -79,6 +81,7 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
db.RunMigration(context.Background(), database, "memories", db.SplitSQL(memory.CreateTableSQL()))
|
db.RunMigration(context.Background(), database, "memories", db.SplitSQL(memory.CreateTableSQL()))
|
||||||
|
db.RunMigration(context.Background(), database, "chat", db.SplitSQL(store.CreateTableSQL()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 3. NATS (in-process) ===
|
// === 3. NATS (in-process) ===
|
||||||
@@ -161,6 +164,14 @@ func main() {
|
|||||||
ctxStore := aiCoreCtx.NewStore(4000, 50)
|
ctxStore := aiCoreCtx.NewStore(4000, 50)
|
||||||
logger.Infof("[context] sliding window ready (4000 tokens, 50 turns)")
|
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 ===
|
// === 10. AI-Core: Sub-session Manager ===
|
||||||
subMgr := subsession.NewManager(
|
subMgr := subsession.NewManager(
|
||||||
&subsession.GeneralProvider{},
|
&subsession.GeneralProvider{},
|
||||||
@@ -238,6 +249,9 @@ func main() {
|
|||||||
if memSvc != nil {
|
if memSvc != nil {
|
||||||
gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc)
|
gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc)
|
||||||
}
|
}
|
||||||
|
if chatStore != nil {
|
||||||
|
gwDeps.ChatStore = chatStore
|
||||||
|
}
|
||||||
gwDeps.VoiceSvc = voiceSvc
|
gwDeps.VoiceSvc = voiceSvc
|
||||||
|
|
||||||
wsHub := ws.NewHub(orch)
|
wsHub := ws.NewHub(orch)
|
||||||
@@ -331,6 +345,31 @@ func (a *perfAdapter) History() []map[string]any {
|
|||||||
return result
|
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.
|
// toolExecAdapter adapts the plugin registry to orchestrator.ToolExecutor.
|
||||||
type toolExecAdapter struct{ reg *plugin.Registry }
|
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) {
|
router.SetMessageHandler(func(msg *bridge.UnifiedMessage) {
|
||||||
sessionID := "bridge-" + msg.Source
|
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 }
|
if err != nil { return }
|
||||||
var fc string
|
var fc string
|
||||||
for t := range tokenCh { fc += t.Content }
|
for t := range tokenCh { fc += t.Content }
|
||||||
|
|||||||
@@ -10,12 +10,23 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
"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.
|
// Store holds conversation history with a sliding window.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[string]*SessionHistory
|
sessions map[string]*SessionHistory
|
||||||
maxTokens int // Maximum tokens to include in context
|
maxTokens int // Maximum tokens to include in context
|
||||||
maxTurns int // Maximum message turns to keep
|
maxTurns int // Maximum message turns to keep
|
||||||
|
persister Persister
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionHistory holds messages for a single session.
|
// 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.
|
// Add appends a message to the session history.
|
||||||
func (s *Store) Add(sessionID string, msg model.Message) {
|
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()
|
s.mu.Lock()
|
||||||
sh, ok := s.sessions[sessionID]
|
sh, ok := s.sessions[sessionID]
|
||||||
if !ok {
|
if !ok {
|
||||||
sh = &SessionHistory{}
|
sh = &SessionHistory{}
|
||||||
s.sessions[sessionID] = sh
|
s.sessions[sessionID] = sh
|
||||||
}
|
}
|
||||||
|
persister := s.persister
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
sh.mu.Lock()
|
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.Messages = sh.Messages[len(sh.Messages)-s.maxTurns:]
|
||||||
}
|
}
|
||||||
sh.mu.Unlock()
|
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.
|
// 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 {
|
func (s *Store) Get(sessionID string) []model.Message {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
sh, ok := s.sessions[sessionID]
|
sh, ok := s.sessions[sessionID]
|
||||||
|
persister := s.persister
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
if !ok {
|
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()
|
sh.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePersister records saved messages and can restore them.
|
||||||
|
type fakePersister struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
saved map[string][]model.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakePersister() *fakePersister {
|
||||||
|
return &fakePersister{saved: map[string][]model.Message{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePersister) SaveMessage(sessionID, userID string, msg model.Message) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.saved[sessionID] = append(f.saved[sessionID], msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePersister) LoadRecent(sessionID string, limit int) []model.Message {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.saved[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePersister) count(sessionID string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.saved[sessionID])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersisterSaveOnAdd(t *testing.T) {
|
||||||
|
s := NewStore(4000, 50)
|
||||||
|
p := newFakePersister()
|
||||||
|
s.SetPersister(p)
|
||||||
|
|
||||||
|
s.AddWithUser("s1", "u1", model.Message{Role: "user", Content: "你好"})
|
||||||
|
|
||||||
|
// SaveMessage is dispatched asynchronously — wait briefly
|
||||||
|
for i := 0; i < 50 && p.count("s1") == 0; i++ {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if p.count("s1") != 1 {
|
||||||
|
t.Fatalf("expected 1 persisted message, got %d", p.count("s1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersisterSkipsNonChatRoles(t *testing.T) {
|
||||||
|
s := NewStore(4000, 50)
|
||||||
|
p := newFakePersister()
|
||||||
|
s.SetPersister(p)
|
||||||
|
|
||||||
|
s.Add("s1", model.Message{Role: "system", Content: "上下文块"})
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if p.count("s1") != 0 {
|
||||||
|
t.Fatalf("system messages should not be persisted, got %d", p.count("s1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreFromPersisterOnGet(t *testing.T) {
|
||||||
|
s := NewStore(4000, 50)
|
||||||
|
p := newFakePersister()
|
||||||
|
p.saved["s2"] = []model.Message{
|
||||||
|
{Role: "user", Content: "以前说过的话"},
|
||||||
|
{Role: "assistant", Content: "以前的回复"},
|
||||||
|
}
|
||||||
|
s.SetPersister(p)
|
||||||
|
|
||||||
|
// Session not in memory — should restore from persister
|
||||||
|
msgs := s.Get("s2")
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("expected 2 restored messages, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Content != "以前说过的话" {
|
||||||
|
t.Errorf("unexpected restored content: %q", msgs[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second Get should hit memory, not reload
|
||||||
|
p.saved["s2"] = nil
|
||||||
|
msgs = s.Get("s2")
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Errorf("expected cached messages after restore, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWithoutPersister(t *testing.T) {
|
||||||
|
s := NewStore(4000, 50)
|
||||||
|
if msgs := s.Get("missing"); msgs != nil {
|
||||||
|
t.Errorf("expected nil for unknown session, got %v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ func (p *Pipeline) SetToolExecutor(t ToolExecutor) {
|
|||||||
|
|
||||||
// ProcessInput processes a user message through the full pipeline.
|
// ProcessInput processes a user message through the full pipeline.
|
||||||
// Returns a channel of StreamToken for the LLM response.
|
// Returns a channel of StreamToken for the LLM response.
|
||||||
func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) {
|
func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) {
|
||||||
// 1. Cache check
|
// 1. Cache check
|
||||||
if cached := p.cache.Get(userMessage); cached != nil {
|
if cached := p.cache.Get(userMessage); cached != nil {
|
||||||
ch := make(chan model.StreamToken, 1)
|
ch := make(chan model.StreamToken, 1)
|
||||||
@@ -122,7 +122,7 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Store user message
|
// 7. Store user message
|
||||||
p.ctxStore.Add(sessionID, model.Message{Role: "user", Content: userMessage})
|
p.ctxStore.AddWithUser(sessionID, userID, model.Message{Role: "user", Content: userMessage})
|
||||||
|
|
||||||
return tokenCh, intent, nil
|
return tokenCh, intent, nil
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func (p *Pipeline) streamWithTools(ctx context.Context, adapter *llm.Adapter, me
|
|||||||
// after the response stream completes.
|
// after the response stream completes.
|
||||||
func (p *Pipeline) FinishRound(sessionID, userID, assistantReply string) {
|
func (p *Pipeline) FinishRound(sessionID, userID, assistantReply string) {
|
||||||
if assistantReply != "" {
|
if assistantReply != "" {
|
||||||
p.ctxStore.Add(sessionID, model.Message{Role: "assistant", Content: assistantReply})
|
p.ctxStore.AddWithUser(sessionID, userID, model.Message{Role: "assistant", Content: assistantReply})
|
||||||
}
|
}
|
||||||
p.PostProcess(sessionID, userID)
|
p.PostProcess(sessionID, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestToolCallLoop(t *testing.T) {
|
|||||||
exec := &fakeToolExec{}
|
exec := &fakeToolExec{}
|
||||||
p.SetToolExecutor(exec)
|
p.SetToolExecutor(exec)
|
||||||
|
|
||||||
tokenCh, _, err := p.ProcessInput(context.Background(), "s1", "打开客厅灯")
|
tokenCh, _, err := p.ProcessInput(context.Background(), "s1", "u1", "打开客厅灯")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessInput: %v", err)
|
t.Fatalf("ProcessInput: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,20 +166,60 @@ func (r *Router) notificationHandler(w http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
func (r *Router) messageSearchHandler(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) messageSearchHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
q := req.URL.Query().Get("q")
|
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 }
|
if q == "" { writeJSON(w, 400, M{"error": "missing query"}); return }
|
||||||
// Uses session store DB — returns matching messages
|
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
|
||||||
writeJSON(w, 200, M{"messages": []any{}, "query": q, "user_id": uid, "total": 0})
|
|
||||||
|
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) {
|
func (r *Router) sessionExportHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
|
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/export")
|
||||||
id = strings.TrimSuffix(id, "/export")
|
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
|
||||||
// Export session as markdown
|
|
||||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 200)
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", id[:8]))
|
if err != nil {
|
||||||
w.WriteHeader(200)
|
writeJSON(w, 500, M{"error": err.Error()})
|
||||||
fmt.Fprintf(w, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", id[:8], time.Now().Format(time.RFC3339))
|
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) {
|
func (r *Router) imageAnalyzeHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Interfaces for injected services ---
|
// --- Interfaces for injected services ---
|
||||||
@@ -51,7 +52,7 @@ type VoiceService interface {
|
|||||||
|
|
||||||
// OrchestratorPipeline handles the main dialogue flow.
|
// OrchestratorPipeline handles the main dialogue flow.
|
||||||
type OrchestratorPipeline interface {
|
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.
|
// WebSocketHub manages WS connections.
|
||||||
@@ -85,6 +86,16 @@ type BridgeConfigManager interface {
|
|||||||
DeleteAdapter(name string) error
|
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.
|
// Dependencies holds all injectable services.
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
AuthMgr AuthManager
|
AuthMgr AuthManager
|
||||||
@@ -96,6 +107,7 @@ type Dependencies struct {
|
|||||||
TraceLog TraceLogger
|
TraceLog TraceLogger
|
||||||
PerfMon PerfMonitor
|
PerfMon PerfMonitor
|
||||||
BridgeCfg BridgeConfigManager
|
BridgeCfg BridgeConfigManager
|
||||||
|
ChatStore ChatStorer
|
||||||
ModelConfig *ModelConfigStore
|
ModelConfig *ModelConfigStore
|
||||||
Automation *AutomationStore
|
Automation *AutomationStore
|
||||||
Reminders *ReminderStore
|
Reminders *ReminderStore
|
||||||
@@ -257,29 +269,91 @@ func (r *Router) registerHandler(w http.ResponseWriter, req *http.Request) {
|
|||||||
// --- Sessions ---
|
// --- Sessions ---
|
||||||
|
|
||||||
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
|
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 {
|
switch req.Method {
|
||||||
case "GET":
|
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":
|
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:
|
default:
|
||||||
writeJSON(w, 405, M{"error": "method not allowed"})
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
|
if r.deps.ChatStore == nil {
|
||||||
// Handle export
|
writeJSON(w, 503, M{"error": "chat store unavailable"})
|
||||||
if strings.HasSuffix(id, "/export") {
|
return
|
||||||
id = strings.TrimSuffix(id, "/export")
|
}
|
||||||
|
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)
|
r.sessionExportHandler(w, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasSuffix(rest, "/messages") {
|
||||||
|
r.sessionMessagesHandler(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := rest
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "DELETE":
|
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)
|
writeJSON(w, 204, nil)
|
||||||
case "GET":
|
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:
|
default:
|
||||||
writeJSON(w, 405, M{"error": "method not allowed"})
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestHealth(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoginAndProtectedEndpoint(t *testing.T) {
|
func TestLoginAndProtectedEndpoint(t *testing.T) {
|
||||||
auth := NewStubAuthManager()
|
auth := NewStubAuthManager()
|
||||||
r := New(Dependencies{AuthMgr: auth})
|
r := New(Dependencies{AuthMgr: auth, ChatStore: newFakeChatStore()})
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeChatStore is an in-memory ChatStorer for tests.
|
||||||
|
type fakeChatStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*store.Session
|
||||||
|
messages map[string][]store.Message
|
||||||
|
nextID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeChatStore() *fakeChatStore {
|
||||||
|
return &fakeChatStore{sessions: map[string]*store.Session{}, messages: map[string][]store.Message{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) CreateSession(_ context.Context, id, userID, title string) (*store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
s := &store.Session{ID: id, UserID: userID, Title: title, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
|
f.sessions[id] = s
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) ListSessions(_ context.Context, userID string) ([]store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := []store.Session{}
|
||||||
|
for _, s := range f.sessions {
|
||||||
|
if s.UserID == userID {
|
||||||
|
out = append(out, *s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) DeleteSession(_ context.Context, id, userID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.sessions[id]; !ok {
|
||||||
|
return fmt.Errorf("session not found")
|
||||||
|
}
|
||||||
|
delete(f.sessions, id)
|
||||||
|
delete(f.messages, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) AddMessage(sessionID, role, content string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.nextID++
|
||||||
|
f.messages[sessionID] = append(f.messages[sessionID], store.Message{
|
||||||
|
ID: f.nextID, SessionID: sessionID, Role: role, Content: content, CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) ListMessages(_ context.Context, sessionID string, limit int) ([]store.Message, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
msgs := f.messages[sessionID]
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = []store.Message{}
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) ClearMessages(_ context.Context, sessionID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.messages[sessionID] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatStore) SearchMessages(_ context.Context, userID, query string, limit int) ([]store.Message, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := []store.Message{}
|
||||||
|
for _, msgs := range f.messages {
|
||||||
|
for _, m := range msgs {
|
||||||
|
if strings.Contains(m.Content, query) {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authReq(method, url, body string) *http.Request {
|
||||||
|
var r *http.Request
|
||||||
|
if body != "" {
|
||||||
|
r, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
} else {
|
||||||
|
r, _ = http.NewRequest(method, url, nil)
|
||||||
|
}
|
||||||
|
r.Header.Set("Authorization", "Bearer stub-token-admin")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLifecycle(t *testing.T) {
|
||||||
|
auth := NewStubAuthManager()
|
||||||
|
auth.Login("admin", "admin") // register stub-token-admin
|
||||||
|
chat := newFakeChatStore()
|
||||||
|
r := New(Dependencies{AuthMgr: auth, ChatStore: chat})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
resp, err := http.DefaultClient.Do(authReq("POST", srv.URL+"/api/v1/sessions", `{"title":""}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST sessions: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: expected 201, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
readJSON(t, resp, &created)
|
||||||
|
if created.SessionID == "" {
|
||||||
|
t.Fatal("empty session_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed messages directly
|
||||||
|
chat.AddMessage(created.SessionID, "user", "今晚想吃火锅")
|
||||||
|
chat.AddMessage(created.SessionID, "assistant", "好呀,要不要叫上朋友")
|
||||||
|
|
||||||
|
// List sessions
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions", ""))
|
||||||
|
var listed struct {
|
||||||
|
Sessions []store.Session `json:"sessions"`
|
||||||
|
}
|
||||||
|
readJSON(t, resp, &listed)
|
||||||
|
if len(listed.Sessions) != 1 {
|
||||||
|
t.Fatalf("list: expected 1 session, got %d", len(listed.Sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get messages
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/messages", ""))
|
||||||
|
var msgs struct {
|
||||||
|
Messages []store.Message `json:"messages"`
|
||||||
|
}
|
||||||
|
readJSON(t, resp, &msgs)
|
||||||
|
if len(msgs.Messages) != 2 {
|
||||||
|
t.Fatalf("messages: expected 2, got %d", len(msgs.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/messages/search?q=火锅", ""))
|
||||||
|
var found struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
readJSON(t, resp, &found)
|
||||||
|
if found.Total != 1 {
|
||||||
|
t.Errorf("search: expected 1 hit, got %d", found.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export markdown
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/export", ""))
|
||||||
|
body := readBody(t, resp)
|
||||||
|
if !strings.Contains(body, "火锅") || !strings.Contains(body, "# Cyrene Chat") {
|
||||||
|
t.Errorf("export markdown missing content: %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export json
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/export?format=json", ""))
|
||||||
|
var exported struct {
|
||||||
|
Messages []store.Message `json:"messages"`
|
||||||
|
}
|
||||||
|
readJSON(t, resp, &exported)
|
||||||
|
if len(exported.Messages) != 2 {
|
||||||
|
t.Errorf("export json: expected 2 messages, got %d", len(exported.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID+"/messages", ""))
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("clear: expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Delete session
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID, ""))
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("delete: expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID, ""))
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("delete again: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionsUnavailableWithoutStore(t *testing.T) {
|
||||||
|
auth := NewStubAuthManager()
|
||||||
|
auth.Login("admin", "admin")
|
||||||
|
r := New(Dependencies{AuthMgr: auth})
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, _ := http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions", ""))
|
||||||
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected 503 without store, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON(t *testing.T, resp *http.Response, v any) {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(t *testing.T, resp *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Package store provides PostgreSQL persistence for chat sessions and messages.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session represents a chat session.
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a single chat message.
|
||||||
|
type Message struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Role string `json:"role"` // "user" | "assistant"
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatStore persists sessions and messages in PostgreSQL.
|
||||||
|
type ChatStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatStore creates a store backed by the given database.
|
||||||
|
func NewChatStore(db *sql.DB) *ChatStore {
|
||||||
|
return &ChatStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTableSQL returns the schema for session/message persistence.
|
||||||
|
func CreateTableSQL() string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_user ON chat_messages(user_id, id);
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sessions ---
|
||||||
|
|
||||||
|
// CreateSession creates a new session for the user.
|
||||||
|
func (s *ChatStore) CreateSession(ctx context.Context, id, userID, title string) (*Session, error) {
|
||||||
|
sess := &Session{ID: id, UserID: userID, Title: title}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO chat_sessions (id, user_id, title) VALUES ($1, $2, $3)
|
||||||
|
RETURNING created_at, updated_at
|
||||||
|
`, id, userID, title).Scan(&sess.CreatedAt, &sess.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions returns the user's sessions, most recently active first.
|
||||||
|
func (s *ChatStore) ListSessions(ctx context.Context, userID string) ([]Session, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, title, created_at, updated_at
|
||||||
|
FROM chat_sessions WHERE user_id = $1
|
||||||
|
ORDER BY updated_at DESC LIMIT 100
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sessions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
sessions := []Session{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sess Session
|
||||||
|
if err := rows.Scan(&sess.ID, &sess.UserID, &sess.Title, &sess.CreatedAt, &sess.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions = append(sessions, sess)
|
||||||
|
}
|
||||||
|
return sessions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession removes a session and its messages (CASCADE).
|
||||||
|
func (s *ChatStore) DeleteSession(ctx context.Context, id, userID string) error {
|
||||||
|
res, err := s.db.ExecContext(ctx, `DELETE FROM chat_sessions WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete session: %w", err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return fmt.Errorf("session not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Messages ---
|
||||||
|
|
||||||
|
// AddMessage appends a message, creating the session row if needed and
|
||||||
|
// bumping updated_at. The first user message becomes the session title.
|
||||||
|
func (s *ChatStore) AddMessage(ctx context.Context, sessionID, userID, role, content string) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO chat_sessions (id, user_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING
|
||||||
|
`, sessionID, userID); err != nil {
|
||||||
|
return fmt.Errorf("ensure session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO chat_messages (session_id, user_id, role, content) VALUES ($1, $2, $3, $4)
|
||||||
|
`, sessionID, userID, role, content); err != nil {
|
||||||
|
return fmt.Errorf("insert message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ""
|
||||||
|
if role == "user" {
|
||||||
|
title = truncateRunes(strings.TrimSpace(content), 24)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE chat_sessions SET
|
||||||
|
updated_at = now(),
|
||||||
|
title = CASE WHEN title = '' AND $3 <> '' THEN $3 ELSE title END
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`, sessionID, userID, title); err != nil {
|
||||||
|
return fmt.Errorf("touch session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages returns the most recent messages of a session (ascending order).
|
||||||
|
func (s *ChatStore) ListMessages(ctx context.Context, sessionID string, limit int) ([]Message, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, session_id, role, content, created_at FROM (
|
||||||
|
SELECT id, session_id, role, content, created_at
|
||||||
|
FROM chat_messages WHERE session_id = $1
|
||||||
|
ORDER BY id DESC LIMIT $2
|
||||||
|
) recent ORDER BY id ASC
|
||||||
|
`, sessionID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
messages := []Message{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m Message
|
||||||
|
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
return messages, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearMessages removes all messages of a session.
|
||||||
|
func (s *ChatStore) ClearMessages(ctx context.Context, sessionID string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `DELETE FROM chat_messages WHERE session_id = $1`, sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchMessages does a substring search across the user's messages.
|
||||||
|
func (s *ChatStore) SearchMessages(ctx context.Context, userID, query string, limit int) ([]Message, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, session_id, role, content, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE user_id = $1 AND content ILIKE '%' || $2 || '%'
|
||||||
|
ORDER BY id DESC LIMIT $3
|
||||||
|
`, userID, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
messages := []Message{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m Message
|
||||||
|
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
return messages, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// truncateRunes cuts s to at most n runes, appending an ellipsis when cut.
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:n]) + "…"
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{
|
|||||||
|
|
||||||
// Orchestrator is the interface for the AI dialogue pipeline.
|
// Orchestrator is the interface for the AI dialogue pipeline.
|
||||||
type Orchestrator interface {
|
type Orchestrator 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)
|
||||||
// FinishRound records the assistant reply and triggers post-round work
|
// FinishRound records the assistant reply and triggers post-round work
|
||||||
// (e.g. memory extraction) after the response stream completes.
|
// (e.g. memory extraction) after the response stream completes.
|
||||||
FinishRound(sessionID, userID, assistantReply string)
|
FinishRound(sessionID, userID, assistantReply string)
|
||||||
@@ -160,7 +160,7 @@ func (c *Client) handleChatMessage(msg WSMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Call orchestrator
|
// Call orchestrator
|
||||||
tokenCh, intent, err := c.hub.orchestrator.ProcessInput(context.Background(), msg.SessionID, msg.Content)
|
tokenCh, intent, err := c.hub.orchestrator.ProcessInput(context.Background(), msg.SessionID, c.UserID, msg.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendJSON(WSMessage{Type: "error", Error: err.Error()})
|
c.sendJSON(WSMessage{Type: "error", Error: err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in new issue
Block a user