Bỏ qua

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 ý: messages chỉ trả về role userassistant (bỏ qua tool messages 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), event done trả về session_id mới — lưu lại để tiếp tục thread này.
Sau khi nhận done, 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_id từ 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_id trong 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: null hoặ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/threads sau khi nhận event done.
  • 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_id cũ): 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