"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([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const messagesEndRef = useRef(null); const abortControllerRef = useRef(null); const initialQuerySentRef = useRef(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 (
Daylight Clinical Intelligence (DTQGVN 2018)

Tra Cứu Dược Thư Quốc Gia Việt Nam

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.

{STARTER_QUESTIONS.map((q, idx) => { const Icon = q.icon; return ( ); })}
); } if (resolvedTheme === "glass") { return (
Heavy Glass Liquid Intelligence OS

Hệ Thống Trí Tuệ Y Tế Spatial

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.

{STARTER_QUESTIONS.map((q, idx) => { const Icon = q.icon; return ( ); })}
); } // Default Dark mode return (
Night Laboratory Intelligence Workspace

Trợ Lý Tra Cứu Dược Thư QGVN

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.

{STARTER_QUESTIONS.map((q, idx) => { const Icon = q.icon; return ( ); })}
); }; return (
{/* Citation Beam Overlay for Signature Interaction */} {/* Messages Workspace List */}
{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 ( onCitationClick?.(citation, idx, allCitations) } activeCitationIndex={activeCitationIndex} onRetry={retryQuery ? () => handleSendMessage(retryQuery) : undefined} onQuickReply={(text) => handleSendMessage(text)} /> ); }) )} {/* Loading Indicator */} {isLoading && (

Đang truy xuất Dược thư QGVN 2018...

Đang phân tích chuyên luận & xác thực Entailment

)} {/* Error Notification */} {error && (
{error}
)}
{/* Fixed Composer Bottom Bar */}
); }