"use client"; import React, { useState } from "react"; import type { ChatMessage, Citation } from "@duoc-thu/shared-types"; import { ShieldCheck, FileCheck2, Copy, Check, AlertTriangle, Pill, Sparkles, Info, RotateCcw, ExternalLink, BookOpen, CornerDownRight, } from "lucide-react"; import { cn } from "./lib/utils"; import { citationSectionLabel } from "./CitationCard"; // Display-only patch, 2026-08-13: exactly 3 of 684 catalog entries have "Đ"/ // "đ" in their canonical name (ingestion's slug generator maps every other // Vietnamese diacritic to its base Latin letter, but drops "Đ"/"đ" entirely // instead of mapping it to "D"/"d" — it is not a combining-mark decomposition // under Unicode NFKD, so a generic accent-strip silently loses it). Fixing // the underlying drug_id would mean re-keying every citation chunk_id built // from it in the already-loaded Qdrant corpus, so this only corrects the // header label shown to the user, not the data. Verified against the full // catalog (`ingestion/data/verified/drug_entities.json`): these are the only // 3 affected ids. const DRUG_ID_DISPLAY_OVERRIDES: Record = { giai_oc_to_uon_van_hap_phu_vac_xin_uon_van_hap_phu: "GIẢI ĐỘC TỐ UỐN VÁN HẤP PHỤ (VẮC XIN UỐN VÁN HẤP PHỤ)", khang_oc_to_bach_hau: "KHÁNG ĐỘC TỐ BẠCH HẦU", thuoc_uong_bu_nuoc_va_ien_giai: "THUỐC UỐNG BÙ NƯỚC VÀ ĐIỆN GIẢI", }; function formatResolvedDrugId(resolvedDrugId: string): string { return resolvedDrugId .split(",") .map((id) => id.trim()) .filter(Boolean) .map((id) => DRUG_ID_DISPLAY_OVERRIDES[id] ?? id.replace(/_/g, " ").toUpperCase()) .join(", "); } interface ChatBubbleProps { message: ChatMessage; // `allCitations` is THIS message's own citation list (`message.citations`) // — found live 2026-08-07: the caller previously had no way to know which // message a click came from, so it kept showing whatever citation array a // LATER message had most recently loaded. Clicking [1] on an old answer // (e.g. Omeprazol's mechanism) displayed a completely unrelated later // drug's evidence (e.g. Kanamycin) in the source panel — a real, live- // reported bug for a product whose entire value proposition is a // verifiable citation trail. onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void; activeCitationIndex?: number | null; onRetry?: () => void; /** Fired when the user picks a quick-reply chip instead of typing — sends that text as the next turn. */ onQuickReply?: (text: string) => void; className?: string; } export function ChatBubble({ message, onCitationClick, activeCitationIndex, onRetry, onQuickReply, className, }: ChatBubbleProps) { const [copied, setCopied] = useState(false); const isUser = message.role === "user"; const handleCopy = () => { navigator.clipboard.writeText(message.content); setCopied(true); setTimeout(() => setCopied(false), 2000); }; if (isUser) { return (
{message.content}
); } // Citation markers must render INLINE within whatever block (paragraph/ // bullet/heading) they end the sentence of — never split out as a bare // top-level element. Found live 2026-08-07: the old approach split the // WHOLE content on `[n]` first and only then broke each piece into lines, // so a citation sitting on its own source line (a common shape for a // multi-band dosing answer, e.g. "...5,4 g/ngày\n[1]\n; cấp tính...") ended // up as a bare ` ); }); }; // Helper to parse citations [1], [2] in markdown content const renderStructuredContent = (content: string, citations?: Citation[]) => { const lines = content.split("\n"); return lines.map((line, lineIdx) => { if (!line.trim()) return
; // Heading 2 or 3 if (line.startsWith("### ") || line.startsWith("## ")) { return (

{renderInline(line.replace(/^#+\s*/, ""), `h-${lineIdx}`, citations)}

); } // Bullet points if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) { return (
  • {renderInline(line.trim().replace(/^[-*]\s*/, ""), `li-${lineIdx}`, citations)}
  • ); } // Warning block / Note if (line.includes("Chống chỉ định") || line.includes("Cảnh báo") || line.includes("Thận trọng")) { return (
    {renderInline(line, `w-${lineIdx}`, citations)}
    ); } // The primary clinical answer text — the one thing on this whole card // a clinician actually needs to read, so it carries more visual // weight than the surrounding header/footer chrome (found live // 2026-08-07: previously the same low-emphasis size/weight as // everything else, easy to skim past). return (

    {renderInline(line, `p-${lineIdx}`, citations)}

    ); }); }; const formatBoldText = (text: string) => { const boldParts = text.split(/(\*\*.*?\*\*)/g); return boldParts.map((bPart, bIdx) => { if (bPart.startsWith("**") && bPart.endsWith("**")) { return ( {bPart.slice(2, -2)} ); } return bPart; }); }; const renderAnswerBlocks = () => { if (!message.blocks?.length) return null; return (
    {message.blocks.map((block, blockIndex) => { const warning = block.kind === "warning" && message.answerPlan?.needsWarning; const showHeading = message.blocks!.length > 1 || message.answerPlan?.showHeading; const sourceIds = Array.from(new Set(block.claims.flatMap((claim) => claim.sourceIds))); const sources = sourceIds.flatMap((sourceId) => { const index = message.citations?.findIndex((item) => item.chunkId === sourceId) ?? -1; const citation = index >= 0 ? message.citations?.[index] : undefined; return citation ? [{ citation, index }] : []; }); return (
    {showHeading && (

    {warning && } {block.title}

    )}
      {block.claims.map((claim, claimIndex) => (
    • {message.answerPlan?.layout !== "prose" && ( )}
      {formatBoldText(claim.text)}
    • ))}
    {sources.length > 0 && (
    {sources.map(({ citation, index }) => ( ))}
    )}
    ); })}
    ); }; return (
    {message.resolvedDrugId ? formatResolvedDrugId(message.resolvedDrugId) : "Trợ lý Dược thư"} {message.decision === "answerable" && message.grounded !== false && ( Có căn cứ Dược thư )}
    {/* Document Body */}
    {message.blocks?.length ? renderAnswerBlocks() : renderStructuredContent(message.content, message.citations)}
    {/* Per-message disclaimer — `message.disclaimer` is populated by the backend on every message (including abstain/clarify), never by the model itself (`rag/answer.py`'s DISCLAIMER constant); this was already flowing end-to-end but nothing rendered it, so it only ever surfaced as one page-level banner (`DisclaimerBanner`), not per answer as F-HT #22 ("mọi câu trả lời y tế đều có") requires. */} {message.disclaimer && (

    {message.disclaimer}

    )} {/* Quick-reply chips — only for a clarify turn the model gave a few natural discrete answers to; free text always still works. */} {onQuickReply && message.quickReplies && message.quickReplies.length > 0 && (
    {message.quickReplies.map((reply, idx) => ( ))}
    )}
    ); }