310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
"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<ChatSession[]>([]);
|
|
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
|
const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
|
|
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
|
const [monographPicker, setMonographPicker] = useState<MonographPickerState | null>(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<Citation[]>([]);
|
|
const [activeCitationIndex, setActiveCitationIndex] = useState<number | null>(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<React.SetStateAction<ChatMessage[]>> = (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 <div className="flex flex-1 items-center justify-center text-sm text-txt-muted">Đang tạo phiên tra cứu an toàn...</div>;
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] overflow-hidden bg-app relative">
|
|
{/* Mobile Header Bar Controls */}
|
|
<div className="lg:hidden absolute top-2 left-3 right-3 z-30 flex items-center justify-between pointer-events-none">
|
|
<button
|
|
onClick={() => setShowMobileSidebar(true)}
|
|
aria-label="Mở danh mục phiên"
|
|
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle bg-surface/90 text-txt-primary text-xs font-semibold shadow-elevated backdrop-blur-md"
|
|
>
|
|
<Menu className="w-4 h-4 text-accent-primary" />
|
|
<span>Lịch sử</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowMobileEvidence(!showMobileEvidence)}
|
|
aria-label="Mở thanh bằng chứng"
|
|
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle bg-surface/90 text-txt-primary text-xs font-semibold shadow-elevated backdrop-blur-md"
|
|
>
|
|
<FileSearch className="w-4 h-4 text-accent-primary" />
|
|
<span>Bằng chứng ({citations.length})</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Desktop Region 1: Navigation / Session Sidebar */}
|
|
<Sidebar
|
|
currentSessionId={currentSessionId}
|
|
sessions={sessions}
|
|
onSelectSession={handleSelectSession}
|
|
onNewChat={handleNewChat}
|
|
onDeleteSession={handleDeleteSession}
|
|
onQuickQuery={handleQuickQuery}
|
|
historyRefreshKey={currentMessages.length}
|
|
className="hidden lg:flex"
|
|
/>
|
|
|
|
{/* Mobile Drawer Region 1 */}
|
|
{showMobileSidebar && (
|
|
<div className="fixed inset-0 z-50 flex lg:hidden">
|
|
<div
|
|
className="fixed inset-0 bg-bg-overlay backdrop-blur-sm"
|
|
onClick={() => setShowMobileSidebar(false)}
|
|
/>
|
|
<div className="relative flex flex-col w-4/5 max-w-sm h-full bg-surface z-10 shadow-elevated animate-slide-up">
|
|
<Sidebar
|
|
currentSessionId={currentSessionId}
|
|
sessions={sessions}
|
|
onSelectSession={handleSelectSession}
|
|
onNewChat={handleNewChat}
|
|
onDeleteSession={handleDeleteSession}
|
|
onQuickQuery={handleQuickQuery}
|
|
historyRefreshKey={currentMessages.length}
|
|
className="w-full h-full border-r-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Region 2: Primary Answer Workspace */}
|
|
<main className="flex-1 flex justify-center overflow-hidden relative">
|
|
<ChatPanel
|
|
key={currentSessionId}
|
|
sessionId={currentSessionId}
|
|
messages={currentMessages}
|
|
onMessagesChange={setCurrentMessages}
|
|
initialQuery={queryOverride?.text}
|
|
initialQueryToken={queryOverride?.token}
|
|
onCitationClick={handleCitationClick}
|
|
onCitationsLoaded={handleCitationsLoaded}
|
|
activeCitationIndex={activeCitationIndex}
|
|
monographPicker={monographPicker}
|
|
onMonographChange={setMonographPicker}
|
|
onToggleSection={handleToggleSection}
|
|
className="w-full max-w-4xl h-full"
|
|
/>
|
|
</main>
|
|
|
|
{/* Desktop Region 3: Right Evidence & Source Inspector Panel */}
|
|
{showEvidenceDesktop && (
|
|
<EvidencePanel
|
|
citations={citations}
|
|
activeCitationIndex={activeCitationIndex}
|
|
onSelectCitation={(citation, index) => setActiveCitationIndex(index)}
|
|
monographPicker={monographPicker}
|
|
onToggleSection={handleToggleSection}
|
|
onClose={() => setShowEvidenceDesktop(false)}
|
|
className="hidden lg:flex"
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile Sheet Region 3: Evidence Bottom Sheet / Drawer */}
|
|
{showMobileEvidence && (
|
|
<div className="fixed inset-0 z-50 flex flex-col justify-end lg:hidden">
|
|
<div
|
|
className="fixed inset-0 bg-bg-overlay backdrop-blur-sm"
|
|
onClick={() => setShowMobileEvidence(false)}
|
|
/>
|
|
<div className="relative flex flex-col w-full h-4/5 bg-surface rounded-t-3xl z-10 shadow-elevated overflow-hidden animate-slide-up">
|
|
<EvidencePanel
|
|
citations={citations}
|
|
activeCitationIndex={activeCitationIndex}
|
|
onSelectCitation={(citation, index) => {
|
|
setActiveCitationIndex(index);
|
|
}}
|
|
monographPicker={monographPicker}
|
|
onToggleSection={handleToggleSection}
|
|
onClose={() => setShowMobileEvidence(false)}
|
|
className="w-full h-full border-l-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|