"use client"; import { useEffect, useRef, useState } from "react"; import type { ChatMessage, Citation, MonographPickerState } from "@duoc-thu/shared-types"; import { ChatPanel } from "./_components/ChatPanel"; import { Sidebar, ChatSession } from "./_components/Sidebar"; import { EvidencePanel } from "./_components/EvidencePanel"; import { MessageSquare, FileSearch, Menu, X, Layers } from "lucide-react"; import { cn } from "@duoc-thu/ui"; function createSessionId() { return `session-${crypto.randomUUID()}`; } // The session id is the key both `/api/history` (quick-rerun list) and // `/api/transcript` (full replay) scope their reads by, so persisting it is // a prerequisite for either surviving a refresh at all. The session LIST // itself still lives only in this browser's localStorage — there is no // auth yet to scope a server-side list by, so a different browser/device // (or cleared storage) loses the sidebar entries even though the // transcripts themselves are still in Postgres, keyed by the same id. const SESSIONS_STORAGE_KEY = "dt_sessions"; const CURRENT_SESSION_STORAGE_KEY = "dt_current_session_id"; function loadStoredSessions(): ChatSession[] | null { try { const raw = localStorage.getItem(SESSIONS_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return Array.isArray(parsed) && parsed.length > 0 ? parsed : null; } catch { return null; } } const DEFAULT_SESSION_TITLE = "Phiên tra cứu mới"; // Real usage testing 2026-08-20: a session's sidebar entry stayed // "Phiên tra cứu mới" forever (every session, indistinguishable) and // picking one loaded an empty chat with no way to continue it — the spec's // "history is a re-run shortcut, not a replay" call turned out to read as // broken, not minimal. `/api/transcript` now persists and replays the full // turn (both question and answer), so a session title can be derived from // its own first question instead of staying a placeholder forever. function deriveTitle(text: string): string { const trimmed = text.trim().replace(/\s+/g, " "); return trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed; } export default function ChatPage() { const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(""); const [messagesBySession, setMessagesBySession] = useState>({}); const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null); const [monographPicker, setMonographPicker] = useState(null); // Guards `hydrateSession` to one fetch per session per page load: without // it, switching back and forth between two sessions would re-fetch (and // briefly flash) every time, and a session with messages just sent this // visit could get clobbered by a fetch racing behind it. const hydratedSessionIdsRef = useRef>(new Set()); const hydrateSession = (id: string) => { if (hydratedSessionIdsRef.current.has(id)) return; hydratedSessionIdsRef.current.add(id); fetch(`/api/transcript?conversation_id=${encodeURIComponent(id)}`) .then((res) => (res.ok ? res.json() : { messages: [] })) .then((data: { messages?: ChatMessage[] }) => { const messages = Array.isArray(data.messages) ? data.messages : []; if (messages.length === 0) return; setMessagesBySession((prev) => ({ ...prev, [id]: messages })); const firstUser = messages.find((m) => m.role === "user"); if (firstUser) { setSessions((prev) => prev.map((s) => s.id === id && s.title === DEFAULT_SESSION_TITLE ? { ...s, title: deriveTitle(firstUser.content) } : s ) ); } }) .catch(() => { // Best-effort — the session just starts blank, same as before this // feature existed, rather than blocking on a slow/down ai-service. }); }; useEffect(() => { const stored = loadStoredSessions(); if (stored) { const storedCurrent = (() => { try { return localStorage.getItem(CURRENT_SESSION_STORAGE_KEY); } catch { return null; } })(); setSessions(stored); setMessagesBySession(Object.fromEntries(stored.map((s) => [s.id, []]))); const resolvedId = stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id; setCurrentSessionId(resolvedId); hydrateSession(resolvedId); return; } const id = createSessionId(); setSessions([{ id, title: DEFAULT_SESSION_TITLE, updatedAt: new Date().toISOString() }]); setMessagesBySession({ [id]: [] }); setCurrentSessionId(id); hydratedSessionIdsRef.current.add(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Persist whenever the session list / active session changes — covers // new/deleted sessions and switching between them. Guarded on non-empty // so the pre-mount-effect empty state never overwrites a real stored // list with `[]`. useEffect(() => { if (sessions.length === 0) return; try { localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(sessions)); } catch { // Storage full or unavailable (e.g. private browsing) — the session // list simply won't survive a refresh this time. } }, [sessions]); useEffect(() => { if (!currentSessionId) return; try { localStorage.setItem(CURRENT_SESSION_STORAGE_KEY, currentSessionId); } catch { // ignore — same fallback as above } }, [currentSessionId]); // Citation & Evidence Panel State const [citations, setCitations] = useState([]); const [activeCitationIndex, setActiveCitationIndex] = useState(null); // Responsive Drawer Toggles for Mobile/Tablet const [showMobileSidebar, setShowMobileSidebar] = useState(false); const [showMobileEvidence, setShowMobileEvidence] = useState(false); const [showEvidenceDesktop, setShowEvidenceDesktop] = useState(true); const handleNewChat = () => { const newId = createSessionId(); const newSession: ChatSession = { id: newId, title: DEFAULT_SESSION_TITLE, updatedAt: new Date().toISOString(), }; setSessions((prev) => [newSession, ...prev]); setMessagesBySession((prev) => ({ ...prev, [newId]: [] })); setCurrentSessionId(newId); // Nothing on the server yet for a brand-new session — skip the wasted // round trip `hydrateSession` would otherwise make on first select. hydratedSessionIdsRef.current.add(newId); setQueryOverride(null); setCitations([]); setActiveCitationIndex(null); setMonographPicker(null); setShowMobileSidebar(false); }; const handleSelectSession = (id: string) => { setCurrentSessionId(id); hydrateSession(id); setQueryOverride(null); setCitations([]); setActiveCitationIndex(null); setMonographPicker(null); setShowMobileSidebar(false); }; const handleDeleteSession = (id: string) => { setSessions((prev) => prev.filter((s) => s.id !== id)); setMessagesBySession((prev) => { const next = { ...prev }; delete next[id]; return next; }); if (currentSessionId === id) { const remaining = sessions.filter((s) => s.id !== id); if (remaining.length > 0) { setCurrentSessionId(remaining[0].id); hydrateSession(remaining[0].id); } else { handleNewChat(); } } }; const handleQuickQuery = (query: string) => { setQueryOverride({ text: query, token: Date.now() }); setShowMobileSidebar(false); }; const currentMessages = messagesBySession[currentSessionId] ?? []; const setCurrentMessages: React.Dispatch> = (update) => { const sessionId = currentSessionId; setMessagesBySession((prev) => { const existing = prev[sessionId] ?? []; const next = typeof update === "function" ? update(existing) : update; // Title the session off its own first question immediately, rather // than leaving every session as the identical, indistinguishable // placeholder until a later reload re-fetches the transcript. if (existing.length === 0 && next.length > 0 && next[0].role === "user") { const title = deriveTitle(next[0].content); setSessions((prevSessions) => prevSessions.map((s) => (s.id === sessionId ? { ...s, title } : s)) ); } return { ...prev, [sessionId]: next }; }); }; if (!currentSessionId) { return
Đang tạo phiên tra cứu an toàn...
; } const handleCitationClick = (citation: Citation, index: number, allCitations: Citation[]) => { // Found live 2026-08-07: this used to only set the index into whatever // `citations` array was last loaded (i.e. the MOST RECENT answer's), so // clicking [1] on an older message showed a LATER message's unrelated // drug in the evidence panel (reported live: clicking Omeprazol's own // citation showed Kanamycin). The clicked message's own citation list // must replace the panel's state, not just the index into a stale one. setCitations(allCitations); setActiveCitationIndex(index); setMonographPicker(null); setShowMobileEvidence(true); }; const handleCitationsLoaded = (newCitations: Citation[]) => { setCitations(newCitations); // Deliberately NOT auto-activating citation 1 here (removed // 2026-08-07): this used to fire the beam connector line + card // highlight on every single answer, unprompted, and — since // `CitationBeamOverlay` only recomputes its coordinates on window // resize/scroll, not on the content reflow a just-arrived answer // itself causes — the line frequently ended up pointing at stale // positions, i.e. exactly the "dây trích dẫn dính lung tung" (messy // citation wire) reported live. The beam/highlight now only appears // when the user actually clicks a citation, at which point the // coordinates are computed fresh. }; const handleToggleSection = (sectionKey: string) => { setMonographPicker((current) => { if (!current) return current; const selected = current.selectedSectionKeys.includes(sectionKey) ? current.selectedSectionKeys.filter((key) => key !== sectionKey) : [...current.selectedSectionKeys, sectionKey]; return { ...current, selectedSectionKeys: selected }; }); }; return (
{/* Mobile Header Bar Controls */}
{/* Desktop Region 1: Navigation / Session Sidebar */} {/* Mobile Drawer Region 1 */} {showMobileSidebar && (
setShowMobileSidebar(false)} />
)} {/* Desktop Region 2: Primary Answer Workspace */}
{/* Desktop Region 3: Right Evidence & Source Inspector Panel */} {showEvidenceDesktop && ( setActiveCitationIndex(index)} monographPicker={monographPicker} onToggleSection={handleToggleSection} onClose={() => setShowEvidenceDesktop(false)} className="hidden lg:flex" /> )} {/* Mobile Sheet Region 3: Evidence Bottom Sheet / Drawer */} {showMobileEvidence && (
setShowMobileEvidence(false)} />
{ setActiveCitationIndex(index); }} monographPicker={monographPicker} onToggleSection={handleToggleSection} onClose={() => setShowMobileEvidence(false)} className="w-full h-full border-l-0" />
)}
); }