Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work

This commit is contained in:
2026-08-06 17:21:21 +07:00
parent 1e8cbdb586
commit a4b8e1c4db
78 changed files with 6761 additions and 654 deletions
+241 -10
View File
@@ -1,22 +1,253 @@
import type { ChatMessage } from "@duoc-thu/shared-types";
"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,
} from "lucide-react";
import { cn } from "./lib/utils";
export interface ChatBubbleProps {
interface ChatBubbleProps {
message: ChatMessage;
onCitationClick?: (citation: Citation, index: number) => void;
activeCitationIndex?: number | null;
onRetry?: () => void;
className?: string;
}
export function ChatBubble({ message }: ChatBubbleProps) {
export function ChatBubble({
message,
onCitationClick,
activeCitationIndex,
onRetry,
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 (
<div className={cn("flex w-full justify-end my-3", className)}>
<div className="max-w-2xl rounded-2xl bg-accent-primary text-txt-inverse px-4 py-3 shadow-sm text-sm font-medium leading-relaxed">
{message.content}
</div>
</div>
);
}
// Helper to parse citations [1], [2] in markdown content
const renderStructuredContent = (content: string, citations?: Citation[]) => {
// Split content by citations like [1], [2], etc.
const parts = content.split(/(\[\d+\])/g);
return parts.map((part, i) => {
const match = part.match(/^\[(\d+)\]$/);
if (match) {
const citationIndex = parseInt(match[1], 10);
const citationObj = citations && citations[citationIndex - 1];
const isActive = activeCitationIndex === citationIndex;
return (
<button
key={`cite-${i}`}
id={`citation-marker-${citationIndex}`}
onClick={() => {
if (citationObj) {
onCitationClick?.(citationObj, citationIndex);
}
}}
title={citationObj ? `${citationObj.drugName} (${citationObj.sectionType})` : `Trích dẫn [${citationIndex}]`}
className={cn(
"inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 mx-0.5 rounded-full text-[0.68rem] font-extrabold tracking-tight transition-all align-baseline cursor-pointer select-none",
isActive
? "bg-accent-primary text-txt-inverse scale-110 shadow-md ring-2 ring-accent-glow glass-beam-glow"
: "bg-accent-soft text-accent-primary hover:bg-accent-primary hover:text-txt-inverse border border-border-subtle"
)}
>
[{citationIndex}]
</button>
);
}
// Format markdown-like text lines
const lines = part.split("\n");
return (
<React.Fragment key={`text-${i}`}>
{lines.map((line, lineIdx) => {
if (!line.trim()) return <br key={lineIdx} />;
// Heading 2 or 3
if (line.startsWith("### ") || line.startsWith("## ")) {
return (
<h3 key={lineIdx} className="text-base font-extrabold text-txt-primary mt-3 mb-1.5 flex items-center gap-2">
<span className="w-1.5 h-4 rounded-full bg-accent-primary inline-block" />
{line.replace(/^#+\s*/, "")}
</h3>
);
}
// Bullet points
if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) {
return (
<li key={lineIdx} className="ml-4 list-disc text-txt-secondary mb-1">
{formatBoldText(line.trim().replace(/^[-*]\s*/, ""))}
</li>
);
}
// Warning block / Note
if (line.includes("Chống chỉ định") || line.includes("Cảnh báo") || line.includes("Thận trọng")) {
return (
<div key={lineIdx} className="my-2 rounded-xl border border-status-warning/40 bg-status-warning-bg/60 p-3 text-xs leading-relaxed text-txt-primary flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-status-warning shrink-0 mt-0.5" />
<div>{formatBoldText(line)}</div>
</div>
);
}
return (
<p key={lineIdx} className="mb-2 text-txt-primary text-sm leading-relaxed">
{formatBoldText(line)}
</p>
);
})}
</React.Fragment>
);
});
};
const formatBoldText = (text: string) => {
const boldParts = text.split(/(\*\*.*?\*\*)/g);
return boldParts.map((bPart, bIdx) => {
if (bPart.startsWith("**") && bPart.endsWith("**")) {
return (
<strong key={bIdx} className="font-bold text-txt-primary">
{bPart.slice(2, -2)}
</strong>
);
}
return bPart;
});
};
return (
<div
<article
className={cn(
"max-w-lg rounded-2xl px-4 py-3 text-[1.02rem] leading-relaxed shadow-sm",
isUser
? "ml-auto rounded-br-sm bg-primary text-primary-foreground"
: "mr-auto rounded-bl-sm bg-muted text-foreground"
"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",
className
)}
>
<p className="m-0 whitespace-pre-wrap">{message.content}</p>
</div>
{/* 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">
<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>
<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.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">
<ShieldCheck className="h-3 w-3" />
ENTAILED & GROUNDED
</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
</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>
</header>
{/* Document Body */}
<div className="p-4 sm:p-5 medical-document-body">
{renderStructuredContent(message.content, message.citations)}
</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>
<div className="flex items-center gap-2">
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors text-xs font-medium"
>
<RotateCcw className="h-3.5 w-3.5" />
<span>Thử lại</span>
</button>
)}
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors text-xs font-medium"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-status-success" />
<span className="text-status-success font-bold">Đã chép</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
<span>Sao chép</span>
</>
)}
</button>
</div>
</footer>
</article>
);
}