Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
+241
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user