Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^13.0.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"tailwind-merge": "^2.4.0"
|
||||
},
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTheme } from "./ThemeContext";
|
||||
|
||||
interface CitationBeamOverlayProps {
|
||||
activeCitationIndex: number | null;
|
||||
}
|
||||
|
||||
interface Coords {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export function CitationBeamOverlay({ activeCitationIndex }: CitationBeamOverlayProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCitationIndex) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCoords = () => {
|
||||
const markerEl = document.getElementById(`citation-marker-${activeCitationIndex}`);
|
||||
const cardEl = document.getElementById(`citation-card-${activeCitationIndex}`);
|
||||
|
||||
if (!markerEl || !cardEl) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerRect = markerEl.getBoundingClientRect();
|
||||
const cardRect = cardEl.getBoundingClientRect();
|
||||
|
||||
// Ensure both elements are visible on screen
|
||||
if (markerRect.width === 0 || cardRect.width === 0) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCoords({
|
||||
x1: markerRect.left + markerRect.width / 2,
|
||||
y1: markerRect.top + markerRect.height / 2,
|
||||
x2: cardRect.left,
|
||||
y2: cardRect.top + cardRect.height / 2,
|
||||
});
|
||||
};
|
||||
|
||||
updateCoords();
|
||||
const handleScrollOrResize = () => updateCoords();
|
||||
|
||||
window.addEventListener("resize", handleScrollOrResize);
|
||||
window.addEventListener("scroll", handleScrollOrResize, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleScrollOrResize);
|
||||
window.removeEventListener("scroll", handleScrollOrResize, true);
|
||||
};
|
||||
}, [activeCitationIndex]);
|
||||
|
||||
if (!activeCitationIndex || !coords) return null;
|
||||
|
||||
// Compute smooth bezier curve control points
|
||||
const dx = Math.abs(coords.x2 - coords.x1);
|
||||
const cx1 = coords.x1 + dx * 0.4;
|
||||
const cy1 = coords.y1;
|
||||
const cx2 = coords.x2 - dx * 0.4;
|
||||
const cy2 = coords.y2;
|
||||
|
||||
const pathD = `M ${coords.x1} ${coords.y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${coords.x2} ${coords.y2}`;
|
||||
|
||||
const isGlass = resolvedTheme === "glass";
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
{/* Luminous Specular Gradient */}
|
||||
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="var(--accent-primary)" stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor="#38BDF8" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="var(--accent-primary)" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="beamGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation={isGlass ? "6" : "3"} result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Background Outer Glow Line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="var(--accent-glow)"
|
||||
strokeWidth={isGlass ? "8" : "4"}
|
||||
strokeLinecap="round"
|
||||
filter="url(#beamGlow)"
|
||||
className="opacity-70"
|
||||
/>
|
||||
|
||||
{/* Main Connection Path Line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="url(#beamGradient)"
|
||||
strokeWidth={isGlass ? "3" : "2"}
|
||||
strokeDasharray={isGlass ? "8 4" : "none"}
|
||||
strokeLinecap="round"
|
||||
className={isGlass ? "animate-pulse-beam" : "opacity-90"}
|
||||
/>
|
||||
|
||||
{/* Start Dot at Citation Marker */}
|
||||
<circle
|
||||
cx={coords.x1}
|
||||
cy={coords.y1}
|
||||
r={isGlass ? "6" : "4"}
|
||||
fill="var(--accent-primary)"
|
||||
className="animate-ping opacity-75"
|
||||
/>
|
||||
<circle
|
||||
cx={coords.x1}
|
||||
cy={coords.y1}
|
||||
r="4"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* End Dot at Citation Card */}
|
||||
<circle
|
||||
cx={coords.x2}
|
||||
cy={coords.y2}
|
||||
r={isGlass ? "6" : "4"}
|
||||
fill="#38BDF8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,121 @@
|
||||
import { FileText } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Citation } from "@duoc-thu/shared-types";
|
||||
import { badgeVariants } from "./primitives/badge";
|
||||
import { BookOpen, FileText, CheckCircle2, ChevronRight } from "lucide-react";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
export interface CitationCardProps {
|
||||
interface CitationCardProps {
|
||||
citation: Citation;
|
||||
onClick?: () => void;
|
||||
index: number;
|
||||
isActive?: boolean;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CitationCard({ citation, onClick }: CitationCardProps) {
|
||||
const [fromPage, toPage] = citation.sourcePageRange;
|
||||
const className = cn(
|
||||
badgeVariants({ variant: "outline" }),
|
||||
"mr-1.5 mt-1 border-primary/20 bg-primary/5 font-normal text-foreground",
|
||||
onClick && "cursor-pointer transition-colors hover:bg-primary/10"
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<FileText className="h-3.5 w-3.5 text-primary" aria-hidden="true" />
|
||||
<span className="font-bold text-primary">{citation.drugName}</span>
|
||||
<span className="text-muted-foreground">{citation.sectionType}</span>
|
||||
<span className="text-muted-foreground">
|
||||
tr. {fromPage}
|
||||
{toPage !== fromPage ? `–${toPage}` : ""}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
chi_dinh: "Chỉ định",
|
||||
chong_chi_dinh: "Chống chỉ định",
|
||||
lieu_dung: "Liều lượng & Cách dùng",
|
||||
tac_dung_phu: "Tác dụng không mong muốn (ADR)",
|
||||
tuong_tac_thuoc: "Tương tác thuốc",
|
||||
duoc_ly: "Dược lý & Cơ chế tác dụng",
|
||||
than_trong: "Thận trọng khi dùng",
|
||||
qua_lieu: "Quá liều & Xử trí",
|
||||
bao_quan: "Bảo quản",
|
||||
};
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function CitationCard({
|
||||
citation,
|
||||
index,
|
||||
isActive = false,
|
||||
onSelect,
|
||||
className,
|
||||
}: CitationCardProps) {
|
||||
const sectionLabel = SECTION_LABELS[citation.sectionType] ?? citation.sectionType ?? "Chuyên luận";
|
||||
const pageRangeText = citation.sourcePageRange
|
||||
? `Trang ${citation.sourcePageRange[0]}${
|
||||
citation.sourcePageRange[1] && citation.sourcePageRange[1] !== citation.sourcePageRange[0]
|
||||
? `–${citation.sourcePageRange[1]}`
|
||||
: ""
|
||||
}`
|
||||
: "Dược thư 2018";
|
||||
|
||||
return <div className={className}>{content}</div>;
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
id={`citation-card-${index}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex flex-col gap-2 rounded-2xl border p-3.5 transition-all cursor-pointer select-none",
|
||||
isActive
|
||||
? "bg-accent-soft/30 border-border-accent shadow-elevated glass-beam-glow ring-2 ring-accent-primary/20"
|
||||
: "bg-surface border-border-subtle hover:border-border-active hover:bg-surface-elevated shadow-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-[0.7rem] font-extrabold tracking-tight transition-colors",
|
||||
isActive
|
||||
? "bg-accent-primary text-txt-inverse shadow-sm"
|
||||
: "bg-surface-elevated text-txt-secondary border border-border-subtle group-hover:border-border-active"
|
||||
)}
|
||||
>
|
||||
{index}
|
||||
</span>
|
||||
<h4 className="m-0 truncate text-xs font-bold text-txt-primary">
|
||||
{citation.drugName || "Chuyên luận Dược thư"}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border-subtle bg-surface-elevated px-2 py-0.5 text-[0.65rem] font-semibold text-txt-secondary">
|
||||
<BookOpen className="h-2.5 w-2.5 text-accent-primary" />
|
||||
{pageRangeText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-accent-soft px-2 py-0.5 text-[0.68rem] font-semibold text-accent-primary">
|
||||
<FileText className="h-3 w-3" />
|
||||
{sectionLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Snippet / Source Excerpt */}
|
||||
{citation.snippet && (
|
||||
<div className="relative rounded-xl border border-border-subtle bg-surface-elevated/70 p-2.5 text-[0.75rem] leading-relaxed text-txt-secondary italic font-sans">
|
||||
<span className="not-italic text-accent-primary font-bold mr-1">“</span>
|
||||
{citation.snippet}
|
||||
<span className="not-italic text-accent-primary font-bold ml-1">”</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason / Entailment Note */}
|
||||
{citation.reason && (
|
||||
<p className="m-0 text-[0.68rem] leading-snug text-txt-muted flex items-start gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success shrink-0 mt-0.5" />
|
||||
<span>{citation.reason}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end text-[0.65rem] font-medium text-accent-primary group-hover:translate-x-0.5 transition-transform">
|
||||
<span>Xem trích dẫn đầy đủ</span>
|
||||
<ChevronRight className="h-3 w-3 ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "./primitives/alert";
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ShieldAlert, Info, X } from "lucide-react";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
const DEFAULT_TEXT =
|
||||
"Nội dung trả lời được tổng hợp từ Dược thư quốc gia Việt Nam và chỉ mang tính tham khảo, " +
|
||||
"không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
|
||||
|
||||
export interface DisclaimerBannerProps {
|
||||
text?: string;
|
||||
interface DisclaimerBannerProps {
|
||||
className?: string;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export function DisclaimerBanner({ text = DEFAULT_TEXT, className }: DisclaimerBannerProps) {
|
||||
export function DisclaimerBanner({ className, collapsible = true }: DisclaimerBannerProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={cn("bg-surface border-b border-border-subtle px-4 py-1 flex items-center justify-between text-xs text-txt-muted", className)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-status-warning shrink-0" />
|
||||
<span>Thông tin trích từ Dược thư Quốc gia Việt Nam 2018 (Không thay thế chỉ định y khoa).</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="text-accent-primary hover:underline text-[0.7rem] font-medium"
|
||||
>
|
||||
Hiện chi tiết
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="warning"
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-none border-x-0 border-t-0 py-2.5 text-center",
|
||||
"[&>svg]:static [&>svg]:left-auto [&>svg]:top-auto [&>svg~*]:pl-0",
|
||||
"relative flex items-center justify-between gap-3 border-b border-border-subtle bg-surface-elevated/90 px-4 py-2 text-xs text-txt-secondary backdrop-blur-md transition-all shadow-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<AlertDescription>{text}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-status-warning-bg text-status-warning">
|
||||
<ShieldAlert className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<p className="m-0 truncate text-[0.78rem] leading-tight">
|
||||
<strong className="font-semibold text-txt-primary">Cảnh báo lâm sàng:</strong> Nội dung câu trả lời được truy xuất trực tiếp từ Dược thư Quốc gia Việt Nam 2018, chỉ mang tính chất tra cứu chuyên môn và không thay thế chỉ định điều trị của bác sĩ hoặc dược sĩ lâm sàng.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{collapsible && (
|
||||
<button
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="Thu gọn cảnh báo"
|
||||
className="rounded-lg p-1 text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
|
||||
export type ThemeMode = "auto" | "light" | "dark" | "glass";
|
||||
export type ResolvedTheme = "light" | "dark" | "glass";
|
||||
|
||||
interface ThemeContextType {
|
||||
mode: ThemeMode;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "dt_theme_mode";
|
||||
|
||||
export function getAutoTheme(date: Date = new Date()): "light" | "dark" {
|
||||
const hour = date.getHours();
|
||||
return hour >= 6 && hour < 18 ? "light" : "dark";
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
mode: "auto",
|
||||
resolvedTheme: "dark",
|
||||
setMode: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<ThemeMode>("auto");
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("dark");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Initialize theme from localStorage & system time on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as ThemeMode | null;
|
||||
const initialMode = saved && ["auto", "light", "dark", "glass"].includes(saved) ? saved : "auto";
|
||||
setModeState(initialMode);
|
||||
|
||||
const computed = initialMode === "auto" ? getAutoTheme() : initialMode;
|
||||
setResolvedTheme(computed);
|
||||
document.documentElement.setAttribute("data-theme", computed);
|
||||
} catch {
|
||||
const computed = getAutoTheme();
|
||||
setResolvedTheme(computed);
|
||||
document.documentElement.setAttribute("data-theme", computed);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Update theme data-theme attribute and interval timer for auto mode
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const computeResolved = (): ResolvedTheme => {
|
||||
if (mode === "auto") {
|
||||
return getAutoTheme();
|
||||
}
|
||||
return mode;
|
||||
};
|
||||
|
||||
const currentResolved = computeResolved();
|
||||
setResolvedTheme(currentResolved);
|
||||
document.documentElement.setAttribute("data-theme", currentResolved);
|
||||
|
||||
// If auto mode, poll time boundaries every 30 seconds
|
||||
if (mode === "auto") {
|
||||
const interval = setInterval(() => {
|
||||
const nextResolved = getAutoTheme();
|
||||
if (nextResolved !== currentResolved) {
|
||||
setResolvedTheme(nextResolved);
|
||||
document.documentElement.setAttribute("data-theme", nextResolved);
|
||||
}
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [mode, mounted]);
|
||||
|
||||
const setMode = useCallback((newMode: ThemeMode) => {
|
||||
setModeState(newMode);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, newMode);
|
||||
} catch {
|
||||
// Storage unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ mode, resolvedTheme, setMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export const ThemeScript = () => {
|
||||
const code = `
|
||||
(function() {
|
||||
try {
|
||||
var stored = localStorage.getItem('${STORAGE_KEY}');
|
||||
var mode = (stored && ['auto', 'light', 'dark', 'glass'].indexOf(stored) !== -1) ? stored : 'auto';
|
||||
var resolved = mode;
|
||||
if (mode === 'auto') {
|
||||
var hour = new Date().getHours();
|
||||
resolved = (hour >= 6 && hour < 18) ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
return <script dangerouslySetInnerHTML={{ __html: code }} />;
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useTheme, ThemeMode, getAutoTheme } from "./ThemeContext";
|
||||
import { Sun, Moon, Sparkles, Clock, Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
className?: string;
|
||||
variant?: "compact" | "full";
|
||||
}
|
||||
|
||||
export function ThemeSelector({ className, variant = "compact" }: ThemeSelectorProps) {
|
||||
const { mode, resolvedTheme, setMode } = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const options: { id: ThemeMode; label: string; icon: React.ComponentType<{ className?: string }>; desc: string }[] = [
|
||||
{
|
||||
id: "auto",
|
||||
label: "Tự động",
|
||||
icon: Clock,
|
||||
desc: "Chuyển Sáng/Tối theo giờ local (06:00-18:00)",
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
label: "Sáng (Daylight)",
|
||||
icon: Sun,
|
||||
desc: "Tối ưu đọc lâu, phong cách y tế chuẩn mực",
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: "Tối (Night Lab)",
|
||||
icon: Moon,
|
||||
desc: "Dễ chịu ban đêm, tương phản cao, hiện đại",
|
||||
},
|
||||
{
|
||||
id: "glass",
|
||||
label: "Heavy Glass",
|
||||
icon: Sparkles,
|
||||
desc: "Giao diện đa tầng kính spatial, hiệu ứng khúc xạ",
|
||||
},
|
||||
];
|
||||
|
||||
const currentOption = options.find((opt) => opt.id === mode) || options[0];
|
||||
const IconComponent = currentOption.icon;
|
||||
|
||||
if (variant === "full") {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-2 sm:grid-cols-4", className)}>
|
||||
{options.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const isActive = mode === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setMode(opt.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start p-3 rounded-xl border transition-all text-left",
|
||||
isActive
|
||||
? "bg-accent-soft border-border-accent text-accent-primary shadow-sm"
|
||||
: "bg-surface border-border-subtle text-txt-secondary hover:border-border-active hover:text-txt-primary"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full mb-1">
|
||||
<Icon className="w-4 h-4" />
|
||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<span className="text-xs font-semibold">{opt.label}</span>
|
||||
<span className="text-[0.65rem] text-txt-muted line-clamp-2 mt-0.5">{opt.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-block text-left", className)} ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="Select theme mode"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-active text-txt-primary text-xs font-medium transition-all shadow-sm"
|
||||
>
|
||||
<IconComponent className="w-3.5 h-3.5 text-accent-primary" />
|
||||
<span className="capitalize hidden sm:inline">{currentOption.label}</span>
|
||||
{mode === "auto" && (
|
||||
<span className="text-[0.68rem] text-txt-muted font-normal hidden md:inline">
|
||||
({resolvedTheme === "light" ? "Ban ngày" : "Ban đêm"})
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={cn("w-3 h-3 text-txt-muted transition-transform", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 rounded-2xl border border-border-subtle bg-surface p-1.5 shadow-elevated backdrop-blur-xl z-50 animate-scale-in">
|
||||
<div className="px-2 py-1 mb-1 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase border-b border-border-subtle">
|
||||
Chế độ hiển thị (Visual Modes)
|
||||
</div>
|
||||
{options.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const isActive = mode === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => {
|
||||
setMode(opt.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-start gap-2.5 w-full p-2 rounded-xl text-left transition-colors text-xs",
|
||||
isActive
|
||||
? "bg-accent-soft text-accent-primary font-semibold"
|
||||
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 mt-0.5 shrink-0", isActive ? "text-accent-primary" : "text-txt-muted")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{opt.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 shrink-0" />}
|
||||
</div>
|
||||
<p className="text-[0.68rem] text-txt-muted font-normal mt-0.5 line-clamp-1">{opt.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
export * from "./ChatBubble";
|
||||
export * from "./CitationCard";
|
||||
export * from "./ThemeContext";
|
||||
export * from "./ThemeSelector";
|
||||
export * from "./DisclaimerBanner";
|
||||
export * from "./CitationCard";
|
||||
export * from "./ChatBubble";
|
||||
export * from "./CitationBeamOverlay";
|
||||
export * from "./primitives/button";
|
||||
export * from "./primitives/card";
|
||||
export * from "./primitives/input";
|
||||
|
||||
Reference in New Issue
Block a user