"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 { AnswerFeedback } from "./AnswerFeedback"; import { Sparkles, Pill, ShieldCheck, BookOpen, Activity, Zap, Info, AlertCircle, Stethoscope, } from "lucide-react"; import { cn } from "@duoc-thu/ui"; interface ChatPanelProps { sessionId: string; messages: ChatMessage[]; onMessagesChange: React.Dispatch>; initialQuery?: string; initialQueryToken?: number; onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void; onCitationsLoaded?: (citations: Citation[]) => void; activeCitationIndex?: number | null; className?: string; } // The client must never be the thing that gives up first. // // The backend's own per-request budget is 40s (`max_wall_clock_ms` in // `apps/ai-service/config.py`), and that budget is only checked *between* // model calls — `rag/budget.py` says so explicitly: it cannot cancel a boto3 // call already in flight, which is separately bounded by `read_timeout=20` // in `adapters/bedrock_converse.py`. So the backend's real worst case is // ~40s + one in-flight call ≈ 60s, and anything below that truncates work // the server was still legitimately doing. // // Measured against production 2026-08-11 (n=8, sequential, one user): // 6.2 / 6.4 / 8.4 / 10.9 / 12.4 / 21.7 / 25.1 / 40.3 seconds. A 25s limit // cuts off 2 of those 8, including the 25.1s case, which had returned a // correct grounded answer with 2 citations. const REQUEST_TIMEOUT_MS = 65_000; // A single spinner for a minute reads as a hang, so the wait is made // legible rather than merely longer. This is a stopgap for the real fix // (streaming verified claims as they land); it does not make the request // faster, it only stops it looking broken. const SLOW_REQUEST_NOTICE_MS = 15_000; const STARTER_QUESTIONS = [ { category: "Chỉ Định", query: "Levetiracetam được chỉ định trong những trường hợp nào?", icon: Pill, }, { category: "Chống Chỉ Định", query: "Chống chỉ định của Metformin là gì?", icon: Stethoscope, }, { category: "ADR Theo Tần Suất", query: "Tác dụng không mong muốn của Zolpidem là gì?", icon: Activity, }, { category: "Thời Kỳ Mang Thai", query: "Có thể dùng Fluoxetin trong thời kỳ mang thai không?", icon: Zap, }, ]; export function ChatPanel({ sessionId, messages, onMessagesChange: setMessages, initialQuery, initialQueryToken, onCitationClick, onCitationsLoaded, activeCitationIndex = null, className, }: ChatPanelProps) { const { resolvedTheme } = useTheme(); const [isLoading, setIsLoading] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); const [error, setError] = useState(null); const messagesEndRef = useRef(null); const abortControllerRef = useRef(null); const initialQuerySentRef = useRef(undefined); const stopRequestedRef = useRef(false); 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); stopRequestedRef.current = false; abortControllerRef.current = new AbortController(); const timeoutId = window.setTimeout(() => { abortControllerRef.current?.abort(); }, REQUEST_TIMEOUT_MS); setElapsedMs(0); const startedAt = Date.now(); const tickId = window.setInterval(() => { setElapsedMs(Date.now() - startedAt); }, 1000); 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") { setError( stopRequestedRef.current ? "Đã dừng chờ trên giao diện. Tác vụ đang chạy có thể cần vài giây để kết thúc an toàn." // Describes the timing cause rather than suggesting the // question needs rewording, which rephrasing would not fix. : `Hệ thống xử lý quá ${Math.round( REQUEST_TIMEOUT_MS / 1000 )} giây nên đã dừng yêu cầu này. Vui lòng bấm gửi lại.` ); 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 { window.clearTimeout(timeoutId); window.clearInterval(tickId); setElapsedMs(0); setIsLoading(false); abortControllerRef.current = null; } }; const handleStop = () => { if (abortControllerRef.current) { stopRequestedRef.current = true; abortControllerRef.current.abort(); } }; useEffect(() => { return () => abortControllerRef.current?.abort(); }, []); 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 && initialQueryToken !== undefined && initialQuerySentRef.current !== initialQueryToken ) { initialQuerySentRef.current = initialQueryToken; handleSendMessage(initialQuery); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialQuery, initialQueryToken]); // 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

Tra cứu 684 chuyên luận Dược thư Quốc gia Việt Nam 2018 với căn cứ theo trang in. Khi cần, bác sĩ có thể tiếp tục trao đổi để làm rõ dữ kiện và đối chiếu với bối cảnh lâm sàng.

{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={ msgIdx === messages.length - 1 && msg.decision === "clarify" && !isLoading ? (text) => handleSendMessage(text) : undefined } /> {msg.role === "assistant" && msg.traceId && !msg.traceId.startsWith("fallback-") && ( )} ); }) )} {/* Loading Indicator */} {isLoading && (

Đang truy xuất Dược thư QGVN 2018... {elapsedMs >= 1000 && ` ${Math.floor(elapsedMs / 1000)}s`}

{elapsedMs >= SLOW_REQUEST_NOTICE_MS ? "Câu hỏi tra cả mục nên cần thêm thời gian đối chiếu nguồn — vẫn đang xử lý." : "Đang phân tích chuyên luận & xác thực Entailment"}

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