"use client"; import { useEffect, 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()}`; } // Feature-List #25: the session id is the key `/v1/rag/history` scopes its // listing by, so persisting it is a prerequisite for history surviving a // refresh at all — not just a UX nicety. Chat MESSAGE CONTENT is // deliberately NOT persisted here (server never stores generated answer // text/blocks either, only decision/reason metadata — see // `PostgresTraceRepository.list_by_conversation`'s docstring): a resumed // session's transcript starts empty, matching the spec's own "re-run the // query" wording rather than "replay the old answer". 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; } } 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); 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, []]))); setCurrentSessionId(stored.find((s) => s.id === storedCurrent)?.id ?? stored[0].id); return; } const id = createSessionId(); setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]); setMessagesBySession({ [id]: [] }); setCurrentSessionId(id); }, []); // 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: "Phiên tra cứu mới", updatedAt: new Date().toISOString(), }; setSessions((prev) => [newSession, ...prev]); setMessagesBySession((prev) => ({ ...prev, [newId]: [] })); setCurrentSessionId(newId); setQueryOverride(null); setCitations([]); setActiveCitationIndex(null); setMonographPicker(null); setShowMobileSidebar(false); }; const handleSelectSession = (id: string) => { setCurrentSessionId(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); } 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; 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" />
)}
); }