Chat Thread History — Frontend Integration Guide
Tài liệu này hướng dẫn FE tích hợp tính năng lịch sử hội thoại dạng thread (giống ChatGPT sidebar) vào ứng dụng GoalFinder AI.
Tổng quan luồng dữ liệu
App load
└─ GET /chat/threads → render sidebar thread list
Người dùng click thread
└─ GET /chat/threads/{session_id} → load messages → render chat history
Người dùng gửi tin nhắn
└─ POST /chat/message/stream { message, session_id? }
├─ stream SSE tokens → append to chat UI
└─ event "done" { session_id } → lưu session_id, refresh sidebar
Người dùng xoá thread
└─ DELETE /chat/threads/{session_id} → remove khỏi sidebar
Người dùng đổi tên thread
└─ PATCH /chat/threads/{session_id}/title { title } → update sidebar
API Reference
Authentication
Tất cả endpoints đều yêu cầu JWT Bearer token trong header Authorization.
API Gateway tự inject X-User-Id header sau khi verify — FE chỉ cần gửi JWT.
1. List Threads
GET /chat/threads?limit=30&offset=0
Query params:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | int | 30 | Max 100 |
| offset | int | 0 | Pagination offset |
Response 200:
{
"threads": [
{
"session_id": "uuid-v4",
"title": "Tìm sân bóng ở Quận 1",
"preview": "Tôi tìm thấy 3 sân phù hợp: Sân A (300k/h)...",
"message_count": 6,
"created_at": "2026-06-03T10:00:00Z",
"updated_at": "2026-06-03T10:15:00Z"
}
],
"total": 1,
"limit": 30,
"offset": 0
}
2. Get Thread Detail (+ full messages)
GET /chat/threads/{session_id}
Response 200:
{
"session_id": "uuid-v4",
"title": "Tìm sân bóng ở Quận 1",
"preview": "Tôi tìm thấy 3 sân phù hợp...",
"message_count": 6,
"created_at": "2026-06-03T10:00:00Z",
"updated_at": "2026-06-03T10:15:00Z",
"messages": [
{ "role": "user", "content": "Tìm sân 5 người ở Quận 1", "tool_name": null, "created_at": "..." },
{ "role": "assistant", "content": "Tôi tìm thấy 3 sân...", "tool_name": null, "created_at": "..." }
]
}
Lưu ý:
messageschỉ trả về roleuservàassistant(bỏ quatoolmessages nội bộ).
Error 404: Thread không tồn tại hoặc không thuộc user này.
3. Send Message (stream) — không thay đổi
POST /chat/message/stream
Content-Type: application/json
{
"message": "Tìm sân 5 người ở Quận 1",
"session_id": "uuid-v4-hoặc-null"
}
SSE Events:
| Event | Payload | Ý nghĩa |
|-------|---------|---------|
| {"type":"thinking"} | — | Đang xử lý, hiện loading |
| {"type":"tool_calling","tool":"search_fields"} | — | Đang gọi tool |
| {"type":"token","content":"Tôi tìm"} | — | Append vào UI |
| {"type":"done","session_id":"uuid"} | Lưu session_id này | Stream xong |
| data: [DONE] | — | Sentinel cuối stream |
Khi
session_id = null(chat mới), eventdonetrả vềsession_idmới — lưu lại để tiếp tục thread này.
Sau khi nhậndone, thread sẽ tự động được tạo/cập nhật ở backend.
4. Delete Thread
DELETE /chat/threads/{session_id}
Response 200:
{ "message": "Thread deleted.", "session_id": "uuid-v4" }
Error 404: Thread không tồn tại.
5. Rename Thread
PATCH /chat/threads/{session_id}/title
Content-Type: application/json
{ "title": "Tên mới của thread" }
Response 200: Trả về ThreadSummary đã cập nhật.
Implementation Guide (React/Next.js)
State Management
interface ChatThread {
session_id: string;
title: string;
preview: string | null;
message_count: number;
created_at: string;
updated_at: string;
}
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
created_at: string;
}
// App state
const [threads, setThreads] = useState<ChatThread[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
Hook: Load threads on mount
useEffect(() => {
async function loadThreads() {
const res = await fetch('/chat/threads', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setThreads(data.threads);
}
loadThreads();
}, []);
Hook: Resume a thread
async function openThread(sessionId: string) {
const res = await fetch(`/chat/threads/${sessionId}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setActiveSessionId(sessionId);
setMessages(data.messages);
}
Hook: Send streaming message
async function sendMessage(userText: string) {
// Optimistic update
setMessages(prev => [...prev, { role: 'user', content: userText, created_at: new Date().toISOString() }]);
setMessages(prev => [...prev, { role: 'assistant', content: '', created_at: new Date().toISOString() }]);
const res = await fetch('/chat/message/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message: userText, session_id: activeSessionId }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') break;
const event = JSON.parse(raw);
if (event.type === 'token') {
// Append token to last assistant message
setMessages(prev => {
const copy = [...prev];
copy[copy.length - 1] = {
...copy[copy.length - 1],
content: copy[copy.length - 1].content + event.content,
};
return copy;
});
}
if (event.type === 'done') {
// Save session_id + refresh thread list
setActiveSessionId(event.session_id);
refreshThreads(); // re-fetch sidebar
}
}
}
}
Hook: New chat
function startNewChat() {
setActiveSessionId(null);
setMessages([]);
}
Hook: Delete thread
async function deleteThread(sessionId: string) {
await fetch(`/chat/threads/${sessionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
setThreads(prev => prev.filter(t => t.session_id !== sessionId));
if (activeSessionId === sessionId) startNewChat();
}
Hook: Rename thread (inline edit)
async function renameThread(sessionId: string, newTitle: string) {
const res = await fetch(`/chat/threads/${sessionId}/title`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ title: newTitle }),
});
const updated = await res.json();
setThreads(prev => prev.map(t => t.session_id === sessionId ? { ...t, title: updated.title } : t));
}
UI Layout Reference
┌─────────────────────────────────────────────────────────────┐
│ Sidebar (240px) │ Chat area │
│───────────────────────── │─────────────────────────────────│
│ [+ New Chat] │ [Thread title: "Tìm sân Q1"] │
│ │─────────────────────────────────│
│ ● Tìm sân Quận 1 (hôm) │ user: Tìm sân 5 người... │
│ Tôi tìm thấy 3 sân... │ bot: Tôi tìm thấy 3 sân... │
│ │ user: Đặt sân A lúc 18h │
│ ● Đặt sân FTown (hôm) │ bot: Đã đặt thành công... │
│ Đã đặt thành công... │─────────────────────────────────│
│ │ [ Nhập câu hỏi của bạn... →] │
│ ● Sân giá rẻ Q7 (qua) │ │
└─────────────────────────────────────────────────────────────┘
Quan trọng: session_id = thread_id
session_idtừ API chính là thread ID — không có khái niệm riêng biệt.- FE chỉ cần track 1 giá trị
session_idtrong state. - Khi gửi tin nhắn tiếp theo trong cùng thread: luôn truyền
session_idđã có. - Khi bắt đầu chat mới: truyền
session_id: nullhoặc bỏ trống field.
Thread auto-create / auto-title
- Thread được tự động tạo ở backend sau khi gửi tin nhắn đầu tiên (60 ký tự đầu của user message = title).
- FE không cần gọi API tạo thread — chỉ cần refresh
GET /chat/threadssau khi nhận eventdone. - Auto-title không dùng LLM — latency = 0, không tốn thêm API call.
Reload-safe (session restore)
- Người dùng reload trang → mở lại thread cũ bằng
GET /chat/threads/{session_id}→ nhận đủ lịch sử. - Khi tiếp tục chat (gửi message với
session_idcũ): dù Redis TTL hết, backend tự restore từ PostgreSQL. - FE không cần xử lý gì thêm cho trường hợp này.
Error Handling
| HTTP | Ý nghĩa | FE xử lý |
|---|---|---|
401 |
JWT hết hạn hoặc thiếu | Redirect login |
404 |
Thread không tìm thấy | Hiện toast + remove khỏi sidebar |
503 |
DB chưa config (CHATBOT_DB_URL missing) |
Hiện banner "History unavailable" |
429 |
Rate limit | Retry sau 5s với backoff |