Files
duocthu/apps/web/app/page.tsx
T

373 lines
15 KiB
TypeScript

"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<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);
// 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<Set<string>>(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<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: 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<React.SetStateAction<ChatMessage[]>> = (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 <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>
);
}