Files
duocthu/apps/web/app/_components/ChatPanel.tsx
T

369 lines
15 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
import { Composer } from "./Composer";
import {
Sparkles,
Pill,
ShieldCheck,
BookOpen,
Activity,
Zap,
Info,
AlertCircle,
Stethoscope,
} from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface ChatPanelProps {
sessionId: string;
initialQuery?: string;
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
onCitationsLoaded?: (citations: Citation[]) => void;
activeCitationIndex?: number | null;
className?: string;
}
const STARTER_QUESTIONS = [
{
category: "Liều Dùng Lâm Sàng",
query: "Liều dùng Paracetamol người lớn và trẻ em theo cân nặng là bao nhiêu?",
icon: Pill,
},
{
category: "Chống Chỉ Định",
query: "Chống chỉ định tuyệt đối và tương đối của Amoxicillin là gì?",
icon: Stethoscope,
},
{
category: "Tương Tác Thuốc",
query: "Tương tác giữa Metformin và thuốc cản quang chứa iốt xử trí thế nào?",
icon: Activity,
},
{
category: "Thận Trọng & ADR",
query: "Thận trọng khi dùng Aspirin cho bệnh nhân có tiền sử loét dạ dày?",
icon: Zap,
},
];
export function ChatPanel({
sessionId,
initialQuery,
onCitationClick,
onCitationsLoaded,
activeCitationIndex = null,
className,
}: ChatPanelProps) {
const { resolvedTheme } = useTheme();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const initialQuerySentRef = useRef<string | undefined>(undefined);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, isLoading]);
const handleSendMessage = async (userText: string) => {
if (!userText.trim() || isLoading) return;
setError(null);
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: userText,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setIsLoading(true);
abortControllerRef.current = new AbortController();
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: userText,
conversationId: sessionId,
}),
signal: abortControllerRef.current.signal,
});
if (!res.ok) {
throw new Error(`Upstream returned status ${res.status}`);
}
const data: SendMessageResponse = await res.json();
const assistantMsg = data.message;
setMessages((prev) => [...prev, assistantMsg]);
if (assistantMsg.citations && assistantMsg.citations.length > 0) {
onCitationsLoaded?.(assistantMsg.citations);
}
} catch (err: any) {
if (err.name === "AbortError") {
return;
}
setError("Không thể kết nối đến máy chủ AI Service. Vui lòng kiểm tra lại dịch vụ backend.");
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setIsLoading(false);
abortControllerRef.current = null;
}
};
useEffect(() => {
// Guard against firing twice for the same query: React 18 Strict Mode
// (dev only) runs this effect setup twice on mount, and with no guard
// that sent every quick-prompt click as two identical live requests
// (found live 2026-08-07: duplicate "Chỉ định & Tác dụng không mong
// muốn của Aspirin" turns in the trace). The ref persists across the
// Strict Mode replay, so the second invocation for the same
// `initialQuery` is a no-op; a genuinely new query still sends once.
if (initialQuery && initialQuerySentRef.current !== initialQuery) {
initialQuerySentRef.current = initialQuery;
handleSendMessage(initialQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery]);
// Empty state renderer per theme
const renderEmptyState = () => {
if (resolvedTheme === "light") {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-accent-soft text-accent-primary shadow-sm border border-border-accent/30">
<Pill className="h-8 w-8" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-accent/30 bg-accent-soft px-3 py-1 text-xs font-bold text-accent-primary mb-2">
<ShieldCheck className="w-3.5 h-3.5" />
Daylight Clinical Intelligence (DTQGVN 2018)
</span>
<h2 className="text-xl sm:text-2xl font-extrabold text-txt-primary tracking-tight">
Tra Cứu Dược Thư Quốc Gia Việt Nam
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống AI y tế tra cứu chính xác theo 684 chuyên luận chính thức. Mọi thông tin đu đưc xác thực suy luận (Entailment Verification) kèm trích dẫn trang in PDF.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-3.5 rounded-2xl border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-sm group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
{q.category}
</span>
<Sparkles className="w-3.5 h-3.5 text-txt-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
}
if (resolvedTheme === "glass") {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="relative flex h-20 w-20 items-center justify-center rounded-3xl bg-accent-soft text-accent-primary shadow-elevated border border-border-accent glass-panel glass-beam-glow animate-pulse-glow">
<Sparkles className="h-10 w-10" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-accent bg-accent-soft px-3 py-1 text-xs font-extrabold text-accent-primary mb-2 shadow-sm">
<Activity className="w-3.5 h-3.5 text-accent-primary" />
Heavy Glass Liquid Intelligence OS
</span>
<h2 className="text-2xl sm:text-3xl font-extrabold text-txt-primary tracking-tight">
Hệ Thống Trí Tuệ Y Tế Spatial
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Không gian tra cứu đa tầng kính với hiệu ng Citation Beam liên kết trực tiếp khẳng đnh lâm sàng đến trang sách gốc Dược thư 2018.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-4 rounded-2xl border border-border-subtle bg-surface/70 hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-elevated glass-content-card group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-accent-primary" />
{q.category}
</span>
<Sparkles className="w-3.5 h-3.5 text-accent-primary" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
}
// Default Dark mode
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-surface-elevated border border-border-subtle text-accent-primary shadow-elevated">
<BookOpen className="h-8 w-8" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-bold text-accent-primary mb-2">
<ShieldCheck className="w-3.5 h-3.5" />
Night Laboratory Intelligence Workspace
</span>
<h2 className="text-xl sm:text-2xl font-extrabold text-txt-primary tracking-tight">
Trợ Tra Cứu Dược Thư QGVN
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống phân tích & tra cứu Dược thư Quốc gia Việt Nam 2018. Đt câu hỏi lâm sàng đ nhận phân tích căn cứ trích dẫn chính xác.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-3.5 rounded-2xl border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-sm group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
{q.category}
</span>
<Zap className="w-3.5 h-3.5 text-txt-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
};
return (
<section className={cn("flex flex-col h-full overflow-hidden relative", className)}>
{/* Citation Beam Overlay for Signature Interaction */}
<CitationBeamOverlay activeCitationIndex={activeCitationIndex} />
{/* Messages Workspace List */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
{messages.length === 0 ? (
renderEmptyState()
) : (
messages.map((msg, msgIdx) => {
// Retry must resend the ORIGINAL user question, not this
// bubble's own text — for an assistant bubble, `msg.content` is
// the answer/error text itself, so resending it fed the error
// message back in as if it were the next question (found live
// 2026-08-07: a trace row where the query text WAS literally
// "Dịch vụ đang gặp sự cố tạm thời..."). Walk back to the
// nearest preceding user turn instead.
const retryQuery =
msg.role === "assistant"
? [...messages.slice(0, msgIdx)].reverse().find((m) => m.role === "user")?.content
: undefined;
return (
<ChatBubble
key={msg.id}
message={msg}
onCitationClick={(citation, idx, allCitations) =>
onCitationClick?.(citation, idx, allCitations)
}
activeCitationIndex={activeCitationIndex}
onRetry={retryQuery ? () => handleSendMessage(retryQuery) : undefined}
onQuickReply={(text) => handleSendMessage(text)}
/>
);
})
)}
{/* Loading Indicator */}
{isLoading && (
<div className="flex items-center gap-3 p-4 rounded-2xl border border-border-subtle bg-surface max-w-md animate-pulse">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<Pill className="h-4 w-4 animate-spin" />
</div>
<div>
<p className="text-xs font-bold text-txt-primary">Đang truy xuất Dược thư QGVN 2018...</p>
<p className="text-[0.68rem] text-txt-muted">Đang phân tích chuyên luận & xác thực Entailment</p>
</div>
</div>
)}
{/* Error Notification */}
{error && (
<div className="flex items-center justify-between gap-2 p-3.5 rounded-2xl border border-status-danger/40 bg-status-danger-bg text-status-danger text-xs">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
<button
onClick={() => setError(null)}
className="font-bold underline text-[0.7rem]"
>
Đóng
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Fixed Composer Bottom Bar */}
<div className="p-3 sm:p-4 border-t border-border-subtle bg-surface-elevated/60 backdrop-blur-md">
<Composer onSubmit={handleSendMessage} isLoading={isLoading} onStop={handleStop} />
</div>
</section>
);
}