Remove corpus counts from chat chrome

This commit is contained in:
2026-08-10 17:26:58 +07:00
parent 46469468bb
commit 97cb6d16f4
31 changed files with 2192 additions and 424 deletions
+24
View File
@@ -23,6 +23,26 @@ export interface Citation {
sourceCropUrl?: string;
}
export interface AnswerClaim {
text: string;
/** Stable backend chunk ids; resolved against `message.citations` by the UI. */
sourceIds: string[];
}
export interface AnswerBlock {
title: string;
kind: "fact_list" | "warning" | "dosage" | string;
claims: AnswerClaim[];
}
export interface AnswerPlan {
verbosity: "concise" | "normal" | "detailed";
layout: "prose" | "bullet_list" | "dosage" | string;
reasoningMode: "direct_lookup" | "synthesis" | "grounded_inference" | string;
showHeading: boolean;
needsWarning: boolean;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
@@ -44,6 +64,10 @@ export interface ChatMessage {
* and the UI must fall back to free text either way.
*/
quickReplies?: string[];
/** Verified semantic structure from the answer service; never inferred from prose by the UI. */
blocks?: AnswerBlock[];
answerMode?: "concise" | "normal" | "detailed";
answerPlan?: AnswerPlan;
}
export interface SendMessageRequest {
+87 -94
View File
@@ -17,6 +17,7 @@ import {
CornerDownRight,
} from "lucide-react";
import { cn } from "./lib/utils";
import { citationSectionLabel } from "./CitationCard";
interface ChatBubbleProps {
message: ChatMessage;
@@ -167,98 +168,110 @@ export function ChatBubble({
});
};
const renderAnswerBlocks = () => {
if (!message.blocks?.length) return null;
return (
<div className="space-y-5">
{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 (
<section
key={`${block.title}-${blockIndex}`}
className={cn(
warning && "rounded-lg border-l-2 border-status-warning bg-status-warning-bg/25 px-3 py-2"
)}
>
{showHeading && (
<h4 className="mb-2 flex items-center gap-2 text-sm font-bold text-txt-primary">
{warning && <AlertTriangle className="h-4 w-4 shrink-0 text-status-warning" />}
{block.title}
</h4>
)}
<ul className="list-none space-y-2 p-0" style={{ listStyle: "none" }}>
{block.claims.map((claim, claimIndex) => (
<li
key={`${blockIndex}-${claimIndex}`}
className="text-[0.96rem] leading-relaxed text-txt-primary"
style={{ listStyle: "none" }}
>
<div className="flex items-start gap-2.5">
{message.answerPlan?.layout !== "prose" && (
<span className="mt-[0.6rem] h-1.5 w-1.5 shrink-0 rounded-full bg-accent-primary" />
)}
<div>{formatBoldText(claim.text)}</div>
</div>
</li>
))}
</ul>
{sources.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1">
{sources.map(({ citation, index }) => (
<button
key={citation.chunkId}
onClick={() => onCitationClick?.(citation, index + 1, message.citations ?? [])}
className="inline-flex items-center gap-1 text-[0.7rem] font-medium text-txt-muted transition-colors hover:text-accent-primary"
>
<BookOpen className="h-3 w-3" />
Xem căn cứ · {citation.drugName} · {citationSectionLabel(citation.sectionType)} · tr. {citation.sourcePageRange[0]}
</button>
))}
</div>
)}
</section>
);
})}
</div>
);
};
return (
<article
className={cn(
"my-4 w-full rounded-2xl border transition-all shadow-sm glass-content-card",
message.grounded !== false
? "border-border-subtle bg-surface"
: "border-status-warning/30 bg-status-warning-bg/20",
"my-5 w-full",
className
)}
>
{/* Intelligence Document Header */}
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border-subtle bg-surface-elevated px-4 py-2.5 rounded-t-2xl">
<header className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<Pill className="h-4 w-4" />
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-soft text-accent-primary">
<BookOpen className="h-3.5 w-3.5" />
</div>
<div>
<h3 className="m-0 text-xs font-bold tracking-tight text-txt-primary flex items-center gap-1.5">
<span>Báo Cáo Tra Cứu Chuyên Luận Dược Thư</span>
{message.resolvedDrugId && (
<span className="rounded-md bg-accent-soft px-1.5 py-0.5 text-[0.68rem] font-bold text-accent-primary uppercase">
{message.resolvedDrugId}
</span>
)}
</h3>
</div>
</div>
<div className="flex items-center gap-2">
{message.decision === "answerable" && message.grounded !== false ? (
<span className="inline-flex items-center gap-1 rounded-full border border-status-success/30 bg-status-success-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-success">
<span className="text-xs font-semibold text-txt-secondary">
{message.resolvedDrugId ? message.resolvedDrugId.replace(/_/g, " ").toUpperCase() : "Trợ lý Dược thư"}
</span>
{message.decision === "answerable" && message.grounded !== false && (
<span className="inline-flex items-center gap-1 text-[0.68rem] font-medium text-status-success">
<ShieldCheck className="h-3 w-3" />
ENTAILED & GROUNDED
</span>
) : message.decision === "verify_pdf" ? (
<span className="inline-flex items-center gap-1 rounded-full border border-status-warning/30 bg-status-warning-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-warning">
<AlertTriangle className="h-3 w-3" />
CẦN ĐI CHIẾU PDF GỐC
</span>
) : message.decision === "clarify" ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border-accent/40 bg-accent-soft px-2.5 py-0.5 text-[0.65rem] font-extrabold text-accent-primary">
<Info className="h-3 w-3" />
CẦN LÀM CÂU HỎI
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-status-warning/30 bg-status-warning-bg px-2.5 py-0.5 text-[0.65rem] font-extrabold text-status-warning">
<Info className="h-3 w-3" />
THÔNG TIN TRA CỨU MỞ RỘNG
căn cứ Dược thư
</span>
)}
{/* How the answer was produced from the retrieved evidence — only
meaningful for a real answerable turn (rag/answer.py's two
operating modes: LLM paraphrase vs. verbatim quote). A clarify
or verify_pdf turn is neither, so it gets no source-mode pill. */}
{message.decision === "answerable" && message.grounded !== false && message.generated !== undefined && (
<span
title={
message.generated
? "Câu trả lời do LLM diễn giải lại từ chuyên luận gốc, đã qua kiểm tra grounding + entailment."
: "Trích dẫn nguyên văn từ chuyên luận gốc, không qua diễn giải của LLM."
}
className="inline-flex items-center gap-1 rounded-full border border-border-subtle bg-surface-elevated px-2.5 py-0.5 text-[0.65rem] font-bold text-txt-secondary"
>
{message.generated ? (
<>
<Sparkles className="h-3 w-3 text-accent-primary" />
AI diễn giải, đã kiểm chứng
</>
) : (
<>
<BookOpen className="h-3 w-3 text-accent-primary" />
Trích dẫn nguyên văn
</>
)}
</span>
)}
<time className="text-[0.68rem] text-txt-muted hidden sm:inline">
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</time>
</div>
<time className="hidden text-[0.68rem] text-txt-muted sm:inline">
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</time>
</header>
{/* Document Body */}
<div className="p-4 sm:p-5 medical-document-body">
{renderStructuredContent(message.content, message.citations)}
<div className={cn(
"medical-document-body rounded-2xl px-4 py-3.5 sm:px-5",
message.grounded === false ? "bg-status-warning-bg/25" : "bg-surface"
)}>
{message.blocks?.length
? renderAnswerBlocks()
: renderStructuredContent(message.content, message.citations)}
</div>
{/* Quick-reply chips — only for a clarify turn the model gave a few
natural discrete answers to; free text always still works. */}
{message.quickReplies && message.quickReplies.length > 0 && (
{onQuickReply && message.quickReplies && message.quickReplies.length > 0 && (
<div className="px-4 pb-3 flex flex-wrap gap-2">
{message.quickReplies.map((reply, idx) => (
<button
@@ -273,27 +286,7 @@ export function ChatBubble({
</div>
)}
{/* Disclaimer Section inside document */}
{message.disclaimer && (
<div className="mx-4 mb-3 rounded-xl border border-border-subtle bg-surface-elevated/50 p-2.5 text-[0.72rem] text-txt-muted flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-accent-primary shrink-0 mt-0.5" />
<span>{message.disclaimer}</span>
</div>
)}
{/* Document Footer & Actions */}
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle bg-surface-elevated/40 px-4 py-2.5 rounded-b-2xl text-xs text-txt-muted">
<div className="flex items-center gap-2">
{message.citations && message.citations.length > 0 && (
<div className="flex items-center gap-1.5">
<BookOpen className="h-3.5 w-3.5 text-accent-primary" />
<span className="font-semibold text-txt-secondary text-[0.72rem]">
{message.citations.length} Nguồn trích dẫn chính thức
</span>
</div>
)}
</div>
<footer className="mt-1 flex justify-end gap-1 text-xs text-txt-muted">
<div className="flex items-center gap-2">
{onRetry && (
<button
+5 -1
View File
@@ -40,6 +40,10 @@ const SECTION_LABELS: Record<string, string> = {
thong_tin_quy_che: "Thông tin quy chế",
};
export function citationSectionLabel(sectionType: string): string {
return SECTION_LABELS[sectionType] ?? (sectionType.replace(/_/g, " ") || "Chuyên luận");
}
export function CitationCard({
citation,
index,
@@ -47,7 +51,7 @@ export function CitationCard({
onSelect,
className,
}: CitationCardProps) {
const sectionLabel = SECTION_LABELS[citation.sectionType] ?? citation.sectionType ?? "Chuyên luận";
const sectionLabel = citationSectionLabel(citation.sectionType);
const pageRangeText = citation.sourcePageRange
? `Trang ${citation.sourcePageRange[0]}${
citation.sourcePageRange[1] && citation.sourcePageRange[1] !== citation.sourcePageRange[0]