369 lines
15 KiB
TypeScript
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ợ Lý 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ó 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>
|
|
);
|
|
}
|