758 lines
30 KiB
TypeScript
758 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
|
import type {
|
|
AnswerBlock,
|
|
ChatMessage,
|
|
Citation,
|
|
DrugSectionOption,
|
|
MonographPickerState,
|
|
SendMessageResponse,
|
|
} from "@duoc-thu/shared-types";
|
|
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
|
|
import type { PatientProfile } from "@duoc-thu/shared-types";
|
|
import { getPatientProfile } from "@duoc-thu/api-client";
|
|
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<React.SetStateAction<ChatMessage[]>>;
|
|
initialQuery?: string;
|
|
initialQueryToken?: number;
|
|
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
|
|
onCitationsLoaded?: (citations: Citation[]) => void;
|
|
activeCitationIndex?: number | null;
|
|
monographPicker?: MonographPickerState | null;
|
|
onMonographChange?: (picker: MonographPickerState | null) => void;
|
|
onToggleSection?: (sectionKey: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
interface SectionTextResponse {
|
|
drug_id: string;
|
|
section_key: string;
|
|
section_title: string | null;
|
|
parts: Array<{
|
|
part_index: number | null;
|
|
text: string;
|
|
is_quarantined: boolean;
|
|
printed_page_start: number | null;
|
|
printed_page_end: number | null;
|
|
physical_page: number | null;
|
|
}>;
|
|
}
|
|
|
|
/** Prepends a short, natural Vietnamese clause carrying whatever the saved
|
|
* profile has (only the fields actually filled in — an empty profile or a
|
|
* partially-filled one changes nothing it doesn't have data for). Sent on
|
|
* every turn, not just the first: the understanding model's own multi-turn
|
|
* merge already treats a repeated fact as a no-op, so there's no need to
|
|
* track "did we already say this in this conversation" here. */
|
|
function withPatientContext(userText: string, profile: PatientProfile | null): string {
|
|
if (!profile) return userText;
|
|
const parts: string[] = [];
|
|
if (profile.ageText) parts.push(profile.ageText);
|
|
if (profile.weightKg != null) parts.push(`${profile.weightKg} kg`);
|
|
if (profile.renalFunction) parts.push(`thận: ${profile.renalFunction}`);
|
|
if (profile.hepaticFunction) parts.push(`gan: ${profile.hepaticFunction}`);
|
|
if (profile.knownAllergies) parts.push(`dị ứng: ${profile.knownAllergies}`);
|
|
if (parts.length === 0) return userText;
|
|
return `Bệnh nhân ${parts.join(", ")}. ${userText}`;
|
|
}
|
|
|
|
const MONOGRAPH_DISCLAIMER =
|
|
"Nội dung nguyên văn được lấy từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
|
|
|
|
const QUICK_SECTION_KEYS = [
|
|
"chi_dinh",
|
|
"lieu_luong_va_cach_dung",
|
|
"chong_chi_dinh",
|
|
"than_trong",
|
|
];
|
|
|
|
// 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,
|
|
monographPicker,
|
|
onMonographChange,
|
|
onToggleSection,
|
|
className,
|
|
}: ChatPanelProps) {
|
|
const { resolvedTheme } = useTheme();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [responseMode, setResponseMode] = useState<"ai" | "monograph">("ai");
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const initialQuerySentRef = useRef<number | undefined>(undefined);
|
|
const stopRequestedRef = useRef(false);
|
|
// null = anonymous or no saved profile — never touches the outgoing query.
|
|
// Fetched once; `getPatientProfile()` itself returns null on a 401, so an
|
|
// anonymous visitor never even attempts an authenticated call more than once.
|
|
const patientProfileRef = useRef<PatientProfile | null>(null);
|
|
// Which sessionId has already had the patient-context clause sent. Prepending
|
|
// on every single turn (the original design) adds "Bệnh nhân X tuổi, Y kg."
|
|
// noise to queries that have nothing to do with dosing/patient-specific
|
|
// context — measured live: it pushed a plain ADR-listing question
|
|
// (unrelated to age/weight) from a correct answerable answer into
|
|
// evidence_insufficient. `rag/understanding.py`'s multi-turn merge already
|
|
// carries a stated fact forward, so sending it once per conversation is
|
|
// enough — this only resets when `sessionId` itself changes.
|
|
const patientContextSentForSessionRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
getPatientProfile()
|
|
.then((profile) => {
|
|
patientProfileRef.current = profile;
|
|
})
|
|
.catch(() => {
|
|
patientProfileRef.current = null;
|
|
});
|
|
}, []);
|
|
|
|
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 {
|
|
patientContextSentForSessionRef.current = sessionId;
|
|
const res = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
// Prepends saved patient context on the first turn of this session
|
|
// only — never to what's shown in the chat bubble above. The LLM
|
|
// understanding step extracts these fields from free text and
|
|
// carries them across the conversation (rag/understanding.py), so
|
|
// repeating them on every later turn only adds noise to queries
|
|
// that aren't patient-specific (measured: it broke a plain ADR
|
|
// lookup). See patientContextSentForSessionRef above.
|
|
content:
|
|
patientContextSentForSessionRef.current === sessionId
|
|
? userText
|
|
: withPatientContext(userText, patientProfileRef.current),
|
|
conversationId: sessionId,
|
|
responseMode,
|
|
}),
|
|
signal: abortControllerRef.current.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Upstream returned status ${res.status}`);
|
|
}
|
|
|
|
const data: SendMessageResponse = await res.json();
|
|
let assistantMsg = data.message;
|
|
|
|
if (
|
|
assistantMsg.reason === "select_drug_sections" &&
|
|
assistantMsg.resolvedDrugId &&
|
|
!assistantMsg.resolvedDrugId.includes(",")
|
|
) {
|
|
const drugId = assistantMsg.resolvedDrugId;
|
|
const [sectionsResponse, suggestionResponse] = await Promise.all([
|
|
fetch(`/api/sections?drug_id=${encodeURIComponent(drugId)}`, {
|
|
cache: "no-store",
|
|
signal: abortControllerRef.current.signal,
|
|
}),
|
|
fetch(`/api/suggest?q=${encodeURIComponent(userText)}`, {
|
|
cache: "no-store",
|
|
signal: abortControllerRef.current.signal,
|
|
}),
|
|
]);
|
|
if (!sectionsResponse.ok) {
|
|
throw new Error("section_list_unavailable");
|
|
}
|
|
const rawSections = (await sectionsResponse.json()) as {
|
|
sections?: Array<{ section_key: string; section_title: string }>;
|
|
};
|
|
const suggestionData = suggestionResponse.ok
|
|
? ((await suggestionResponse.json()) as { suggestions?: string[] })
|
|
: {};
|
|
const sections: DrugSectionOption[] = (rawSections.sections ?? []).map(
|
|
(section) => ({
|
|
sectionKey: section.section_key,
|
|
sectionTitle: section.section_title,
|
|
})
|
|
);
|
|
const drugName = suggestionData.suggestions?.[0] ?? userText.trim();
|
|
const picker: MonographPickerState = {
|
|
drugId,
|
|
drugName,
|
|
sections,
|
|
selectedSectionKeys: [],
|
|
};
|
|
assistantMsg = {
|
|
...assistantMsg,
|
|
content:
|
|
`Chuyên luận ${drugName} có ${sections.length} mục. ` +
|
|
"Chọn các mục cần xem ở cột bên phải rồi nhấn Gửi tra cứu — " +
|
|
"nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ.",
|
|
sectionOptions: sections,
|
|
};
|
|
onMonographChange?.(picker);
|
|
}
|
|
|
|
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 handleSubmitMonograph = async () => {
|
|
if (!monographPicker || isLoading) return;
|
|
const selected = monographPicker.selectedSectionKeys.length
|
|
? monographPicker.sections.filter((section) =>
|
|
monographPicker.selectedSectionKeys.includes(section.sectionKey)
|
|
)
|
|
: monographPicker.sections;
|
|
if (selected.length === 0) {
|
|
setError("Chuyên luận này chưa có mục văn bản để hiển thị.");
|
|
return;
|
|
}
|
|
|
|
const label = monographPicker.selectedSectionKeys.length
|
|
? selected.map((section) => section.sectionTitle).join(", ")
|
|
: "Toàn bộ chuyên luận";
|
|
const userMsg: ChatMessage = {
|
|
id: `user-monograph-${Date.now()}`,
|
|
role: "user",
|
|
content: `${monographPicker.drugName} — ${label}`,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
setMessages((prev) => [...prev, userMsg]);
|
|
setError(null);
|
|
setIsLoading(true);
|
|
stopRequestedRef.current = false;
|
|
abortControllerRef.current = new AbortController();
|
|
const timeoutId = window.setTimeout(
|
|
() => abortControllerRef.current?.abort(),
|
|
45_000
|
|
);
|
|
|
|
try {
|
|
const responses = await Promise.all(
|
|
selected.map(async (section) => {
|
|
const response = await fetch(
|
|
`/api/section-text?drug_id=${encodeURIComponent(
|
|
monographPicker.drugId
|
|
)}§ion_key=${encodeURIComponent(section.sectionKey)}`,
|
|
{ cache: "no-store", signal: abortControllerRef.current!.signal }
|
|
);
|
|
if (!response.ok) throw new Error("section_text_unavailable");
|
|
return (await response.json()) as SectionTextResponse;
|
|
})
|
|
);
|
|
|
|
const citations: Citation[] = [];
|
|
const blocks: AnswerBlock[] = [];
|
|
for (const response of responses) {
|
|
const claims: AnswerBlock["claims"] = [];
|
|
for (const [index, part] of response.parts.entries()) {
|
|
if (
|
|
part.printed_page_start == null ||
|
|
part.printed_page_end == null ||
|
|
part.physical_page == null
|
|
) {
|
|
throw new Error("section_provenance_missing");
|
|
}
|
|
const chunkId = `${response.drug_id}__${response.section_key}__${
|
|
part.part_index ?? index
|
|
}`;
|
|
citations.push({
|
|
chunkId,
|
|
drugName: monographPicker.drugName.toUpperCase(),
|
|
sectionType: response.section_key,
|
|
sourceDocument: "Dược thư Quốc gia Việt Nam 2018",
|
|
sourcePageRange: [part.printed_page_start, part.printed_page_end],
|
|
physicalPage: part.physical_page,
|
|
snippet: part.text,
|
|
isQuarantined: part.is_quarantined,
|
|
quarantineNotice: part.is_quarantined
|
|
? "Mục này có bảng hoặc công thức cần đối chiếu trực tiếp trang PDF gốc."
|
|
: undefined,
|
|
});
|
|
claims.push({ text: part.text, sourceIds: [chunkId] });
|
|
}
|
|
blocks.push({
|
|
title:
|
|
response.section_title ??
|
|
selected.find((item) => item.sectionKey === response.section_key)
|
|
?.sectionTitle ??
|
|
response.section_key,
|
|
kind: "fact_list",
|
|
claims,
|
|
});
|
|
}
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
id: `monograph-${Date.now()}`,
|
|
role: "assistant",
|
|
content: `Nguyên văn ${selected.length} mục của ${monographPicker.drugName}.`,
|
|
citations,
|
|
disclaimer: MONOGRAPH_DISCLAIMER,
|
|
decision: "answerable",
|
|
reason: "verbatim_sections",
|
|
grounded: true,
|
|
generated: false,
|
|
resolvedDrugId: monographPicker.drugId,
|
|
blocks,
|
|
answerMode: "detailed",
|
|
answerPlan: {
|
|
verbosity: "detailed",
|
|
layout: "bullet_list",
|
|
reasoningMode: "direct_lookup",
|
|
showHeading: true,
|
|
needsWarning: false,
|
|
},
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
setMessages((prev) => [...prev, assistantMsg]);
|
|
onCitationsLoaded?.(citations);
|
|
} catch (err: any) {
|
|
setError(
|
|
err?.name === "AbortError"
|
|
? "Đã dừng tải chuyên luận."
|
|
: "Không thể tải đầy đủ nguyên văn các mục đã chọn. Vui lòng thử lại."
|
|
);
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
setIsLoading(false);
|
|
abortControllerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const handleStop = () => {
|
|
if (abortControllerRef.current) {
|
|
stopRequestedRef.current = true;
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
|
|
const handleResponseModeChange = (mode: "ai" | "monograph") => {
|
|
setResponseMode(mode);
|
|
if (mode === "ai") {
|
|
onMonographChange?.(null);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<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">
|
|
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.
|
|
</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 (
|
|
<React.Fragment key={msg.id}>
|
|
<ChatBubble
|
|
message={msg}
|
|
onCitationClick={(citation, idx, allCitations) =>
|
|
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.sectionOptions &&
|
|
msg.sectionOptions.length > 0 && (
|
|
<div className="ml-10 flex flex-wrap gap-2 px-4 pb-2">
|
|
{QUICK_SECTION_KEYS.flatMap((key) => {
|
|
const section = msg.sectionOptions?.find(
|
|
(item) => item.sectionKey === key
|
|
);
|
|
if (!section) return [];
|
|
const selected =
|
|
monographPicker?.selectedSectionKeys.includes(key) ?? false;
|
|
return [
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => onToggleSection?.(key)}
|
|
className={cn(
|
|
"rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors",
|
|
selected
|
|
? "border-accent-primary bg-accent-primary text-txt-inverse"
|
|
: "border-border-accent bg-accent-soft text-accent-primary hover:bg-accent-primary hover:text-txt-inverse"
|
|
)}
|
|
>
|
|
{section.sectionTitle}
|
|
</button>,
|
|
];
|
|
})}
|
|
</div>
|
|
)}
|
|
{msg.role === "assistant" &&
|
|
msg.traceId &&
|
|
msg.reason !== "select_drug_sections" &&
|
|
!msg.traceId.startsWith("fallback-") && (
|
|
<AnswerFeedback traceId={msg.traceId} conversationId={sessionId} />
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{/* 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...
|
|
{elapsedMs >= 1000 && ` ${Math.floor(elapsedMs / 1000)}s`}
|
|
</p>
|
|
<p className="text-[0.68rem] text-txt-muted">
|
|
{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"}
|
|
</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}
|
|
monographPicker={monographPicker}
|
|
onToggleSection={onToggleSection}
|
|
onClearMonograph={() => onMonographChange?.(null)}
|
|
onSubmitMonograph={handleSubmitMonograph}
|
|
responseMode={responseMode}
|
|
onResponseModeChange={handleResponseModeChange}
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|