feat: implement chat message persistence with Valkey; add session management and history retrieval

This commit is contained in:
2026-06-12 20:54:57 +08:00
parent 197ddaacfe
commit f407a5118f
4 changed files with 225 additions and 13 deletions
+80
View File
@@ -9,10 +9,20 @@ Usage::
valkey = await get_valkey()
await valkey.set("key", "value")
Chat persistence (Valkey)::
from backend.core.database import save_message, get_session_messages
await save_message("session-1", "user", "Hello!")
messages = await get_session_messages("session-1")
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import AsyncIterator
from redis.asyncio import Redis
@@ -88,3 +98,73 @@ async def close_valkey() -> None:
async def close_engine() -> None:
"""Dispose the SQLAlchemy engine (call on shutdown)."""
await _engine.dispose()
# ── Chat persistence (Valkey) ─────────────────────────────────────────────
_MESSAGES_KEY = "chat:{session_id}:messages"
_SESSIONS_SET = "chat:sessions"
async def save_message(
session_id: str,
role: str,
content: str,
valkey: Redis | None = None,
) -> None:
"""Append a chat message to the session history in Valkey."""
if valkey is None:
valkey = await get_valkey()
msg = {
"role": role,
"content": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
key = _MESSAGES_KEY.format(session_id=session_id)
async with valkey.pipeline(transaction=True) as pipe:
pipe.rpush(key, json.dumps(msg))
pipe.sadd(_SESSIONS_SET, session_id)
await pipe.execute()
async def get_session_messages(
session_id: str,
valkey: Redis | None = None,
) -> list[dict[str, str]]:
"""Return all messages for a session as a list of dicts.
Each message has keys ``role``, ``content``, ``timestamp``.
Returns an empty list if the session does not exist.
"""
if valkey is None:
valkey = await get_valkey()
key = _MESSAGES_KEY.format(session_id=session_id)
raw = await valkey.lrange(key, 0, -1)
return [json.loads(item) for item in raw]
async def list_sessions(
valkey: Redis | None = None,
) -> list[str]:
"""Return all known session IDs."""
if valkey is None:
valkey = await get_valkey()
return sorted(await valkey.smembers(_SESSIONS_SET))
async def delete_session(
session_id: str,
valkey: Redis | None = None,
) -> None:
"""Delete a session and its messages from Valkey."""
if valkey is None:
valkey = await get_valkey()
key = _MESSAGES_KEY.format(session_id=session_id)
async with valkey.pipeline(transaction=True) as pipe:
pipe.delete(key)
pipe.srem(_SESSIONS_SET, session_id)
await pipe.execute()
+71 -5
View File
@@ -1,11 +1,21 @@
"""Chat API endpoint — sends prompts to the agent and returns replies."""
"""Chat API endpoint — sends prompts to the agent and returns replies.
Messages are persisted in Valkey so chat history survives server restarts.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.core.agent import AgentService
from backend.core.database import (
get_session_messages,
list_sessions,
save_message,
)
from backend.core.dependencies import get_agent_service
router = APIRouter(prefix="/api/chat", tags=["chat"])
@@ -18,7 +28,21 @@ class ChatRequest(BaseModel):
class ChatResponse(BaseModel):
reply: str
session_id: str | None = None
session_id: str
class MessageOut(BaseModel):
role: str
content: str
timestamp: str
class SessionOut(BaseModel):
session_id: str
message_count: int
# ── POST /api/chat ────────────────────────────────────────────────────────
@router.post("", response_model=ChatResponse)
@@ -26,9 +50,51 @@ async def chat_endpoint(
body: ChatRequest,
agent: AgentService = Depends(get_agent_service),
) -> ChatResponse:
"""Send a user message to the agent and return its reply."""
"""Send a user message to the agent and return its reply.
Saves both the user message and the assistant reply to Valkey.
Assigns a new UUID ``session_id`` if none was provided.
"""
session_id = body.session_id or uuid.uuid4().hex
try:
output = await agent.ask(body.message, session_id=body.session_id)
return ChatResponse(reply=output, session_id=body.session_id)
# Persist the user message.
await save_message(session_id, "user", body.message)
# Ask the agent.
output = await agent.ask(body.message, session_id=session_id)
# Persist the assistant reply.
await save_message(session_id, "assistant", output)
return ChatResponse(reply=output, session_id=session_id)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
# ── GET /api/chat/history ─────────────────────────────────────────────────
class HistoryParams(BaseModel):
session_id: str
@router.get("/history", response_model=list[MessageOut])
async def get_history(session_id: str) -> list[MessageOut]:
"""Return all messages for a given session."""
messages = await get_session_messages(session_id)
return [MessageOut(**msg) for msg in messages]
# ── GET /api/chat/sessions ────────────────────────────────────────────────
@router.get("/sessions", response_model=list[SessionOut])
async def sessions_list() -> list[SessionOut]:
"""Return all known session IDs with their message counts."""
ids = await list_sessions()
result: list[SessionOut] = []
for sid in ids:
msgs = await get_session_messages(sid)
result.append(SessionOut(session_id=sid, message_count=len(msgs)))
return result
+50 -7
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { sendMessage, type ChatResponse } from "./api";
import { sendMessage, getHistory, type MessageData } from "./api";
/* ------------------------------------------------------------------ */
/* Types */
@@ -10,19 +10,60 @@ interface Message {
content: string;
}
const STORAGE_KEY = "agent_alpha_session_id";
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function App() {
const [messages, setMessages] = useState<Message[]>([
{ role: "assistant", content: "Hello! I'm **Agent Alpha**. How can I help you today?" },
]);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>();
const [sessionId, setSessionId] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY) ?? "";
});
const bottomRef = useRef<HTMLDivElement>(null);
/* Restore chat history on mount */
useEffect(() => {
const sid = localStorage.getItem(STORAGE_KEY);
if (sid) {
getHistory(sid)
.then((history: MessageData[]) => {
if (history.length > 0) {
setMessages(
history.map((m) => ({ role: m.role, content: m.content })),
);
} else {
setMessages([
{
role: "assistant",
content:
"Hello! I'm **Agent Alpha**. How can I help you today?",
},
]);
}
})
.catch(() => {
setMessages([
{
role: "assistant",
content:
"Hello! I'm **Agent Alpha**. How can I help you today?",
},
]);
});
} else {
setMessages([
{
role: "assistant",
content: "Hello! I'm **Agent Alpha**. How can I help you today?",
},
]);
}
}, []);
/* Auto-scroll on new messages */
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -38,8 +79,10 @@ export default function App() {
setLoading(true);
try {
const data: ChatResponse = await sendMessage(text, sessionId);
setSessionId(data.session_id ?? undefined);
const data = await sendMessage(text, sessionId || undefined);
// Persist session_id so future refreshes restore history.
localStorage.setItem(STORAGE_KEY, data.session_id);
setSessionId(data.session_id);
setMessages((prev) => [
...prev,
{ role: "assistant", content: data.reply },
+24 -1
View File
@@ -1,10 +1,20 @@
const API_BASE = "/api";
/* ── Types ──────────────────────────────────────────────────────── */
export interface ChatResponse {
reply: string;
session_id: string | null;
session_id: string;
}
export interface MessageData {
role: "user" | "assistant";
content: string;
timestamp: string;
}
/* ── Send a message ────────────────────────────────────────────── */
export async function sendMessage(
message: string,
sessionId?: string,
@@ -20,3 +30,16 @@ export async function sendMessage(
}
return res.json();
}
/* ── Load session history ──────────────────────────────────────── */
export async function getHistory(sessionId: string): Promise<MessageData[]> {
const res = await fetch(
`${API_BASE}/chat/history?session_id=${encodeURIComponent(sessionId)}`,
);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
return res.json();
}