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
+319 -97
View File
@@ -1,121 +1,343 @@
"use client";
import { useState } from "react";
import { Pill, Send } from "lucide-react";
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
import { ChatBubble, CitationCard, Card, Input, Button, cn } from "@duoc-thu/ui";
import { sendChatMessage } from "@duoc-thu/api-client";
import React, { useState, useEffect, useRef } from "react";
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
import { Composer } from "./Composer";
import {
Sparkles,
Pill,
ShieldCheck,
BookOpen,
Activity,
Zap,
Info,
AlertCircle,
Stethoscope,
} from "lucide-react";
import { cn } from "@duoc-thu/ui";
function TypingIndicator() {
return (
<div className="inline-flex items-center gap-1 px-4 py-3" aria-label="Đang soạn câu trả lời">
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60" />
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.3s]" />
</div>
);
}
export interface ChatPanelProps {
onCitationClick?: (citation: Citation) => void;
interface ChatPanelProps {
sessionId: string;
initialQuery?: string;
onCitationClick?: (citation: Citation, index: number) => void;
onCitationsLoaded?: (citations: Citation[]) => void;
activeCitationIndex?: number | null;
className?: string;
}
const ERROR_MESSAGE =
"Hệ thống tạm thời không phản hồi. Vui lòng thử lại — nếu vẫn lỗi, có thể dịch vụ tra cứu đang tạm ngưng.";
const STARTER_QUESTIONS = [
{
category: "Liều Dùng Lâm Sàng",
query: "Liều dùng Paracetamol người lớn và trẻ em theo cân nặng là bao nhiêu?",
icon: Pill,
},
{
category: "Chống Chỉ Định",
query: "Chống chỉ định tuyệt đối và tương đối của Amoxicillin là gì?",
icon: Stethoscope,
},
{
category: "Tương Tác Thuốc",
query: "Tương tác giữa Metformin và thuốc cản quang chứa iốt xử trí thế nào?",
icon: Activity,
},
{
category: "Thận Trọng & ADR",
query: "Thận trọng khi dùng Aspirin cho bệnh nhân có tiền sử loét dạ dày?",
icon: Zap,
},
];
export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
export function ChatPanel({
sessionId,
initialQuery,
onCitationClick,
onCitationsLoaded,
activeCitationIndex = null,
className,
}: ChatPanelProps) {
const { resolvedTheme } = useTheme();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
// One id per chat session, so follow-ups ("còn trẻ em thì sao?") resolve
// against the same conversation on the backend.
const [conversationId] = useState(() =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `conv-${Date.now()}`
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const content = input.trim();
if (!content || isSending) return;
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const userMessage: ChatMessage = {
id: `local-${messages.length}`,
useEffect(() => {
scrollToBottom();
}, [messages, isLoading]);
const handleSendMessage = async (userText: string) => {
if (!userText.trim() || isLoading) return;
setError(null);
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content,
content: userText,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsSending(true);
setMessages((prev) => [...prev, userMsg]);
setIsLoading(true);
abortControllerRef.current = new AbortController();
try {
const response = await sendChatMessage(content, conversationId);
setMessages((prev) => [...prev, response.message]);
} catch {
// Never leave the user staring at their own message with no reply: an
// error is surfaced as a labelled bubble, not swallowed silently.
setMessages((prev) => [
...prev,
{
id: `error-${messages.length}`,
role: "assistant",
content: ERROR_MESSAGE,
createdAt: new Date().toISOString(),
},
]);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: userText,
conversationId: sessionId,
}),
signal: abortControllerRef.current.signal,
});
if (!res.ok) {
throw new Error(`Upstream returned status ${res.status}`);
}
const data: SendMessageResponse = await res.json();
const assistantMsg = data.message;
setMessages((prev) => [...prev, assistantMsg]);
if (assistantMsg.citations && assistantMsg.citations.length > 0) {
onCitationsLoaded?.(assistantMsg.citations);
}
} catch (err: any) {
if (err.name === "AbortError") {
return;
}
setError("Không thể kết nối đến máy chủ AI Service. Vui lòng kiểm tra lại dịch vụ backend.");
} finally {
setIsSending(false);
setIsLoading(false);
abortControllerRef.current = null;
}
}
};
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setIsLoading(false);
abortControllerRef.current = null;
}
};
useEffect(() => {
if (initialQuery) {
handleSendMessage(initialQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuery]);
// Empty state renderer per theme
const renderEmptyState = () => {
if (resolvedTheme === "light") {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-accent-soft text-accent-primary shadow-sm border border-border-accent/30">
<Pill className="h-8 w-8" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-accent/30 bg-accent-soft px-3 py-1 text-xs font-bold text-accent-primary mb-2">
<ShieldCheck className="w-3.5 h-3.5" />
Daylight Clinical Intelligence (DTQGVN 2018)
</span>
<h2 className="text-xl sm:text-2xl font-extrabold text-txt-primary tracking-tight">
Tra Cứu Dược Thư Quốc Gia Việt Nam
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống AI y tế tra cứu chính xác theo 684 chuyên luận chính thức. Mọi thông tin đu đưc xác thực suy luận (Entailment Verification) kèm trích dẫn trang in PDF.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-3.5 rounded-2xl border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-sm group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
{q.category}
</span>
<Sparkles className="w-3.5 h-3.5 text-txt-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
}
if (resolvedTheme === "glass") {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="relative flex h-20 w-20 items-center justify-center rounded-3xl bg-accent-soft text-accent-primary shadow-elevated border border-border-accent glass-panel glass-beam-glow animate-pulse-glow">
<Sparkles className="h-10 w-10" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-accent bg-accent-soft px-3 py-1 text-xs font-extrabold text-accent-primary mb-2 shadow-sm">
<Activity className="w-3.5 h-3.5 text-accent-primary" />
Heavy Glass Liquid Intelligence OS
</span>
<h2 className="text-2xl sm:text-3xl font-extrabold text-txt-primary tracking-tight">
Hệ Thống Trí Tuệ Y Tế Spatial
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Không gian tra cứu đa tầng kính với hiệu ng Citation Beam liên kết trực tiếp khẳng đnh lâm sàng đến trang sách gốc Dược thư 2018.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-4 rounded-2xl border border-border-subtle bg-surface/70 hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-elevated glass-content-card group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-accent-primary" />
{q.category}
</span>
<Sparkles className="w-3.5 h-3.5 text-accent-primary" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
}
// Default Dark mode
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] py-8 px-4 text-center max-w-3xl mx-auto space-y-6">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-surface-elevated border border-border-subtle text-accent-primary shadow-elevated">
<BookOpen className="h-8 w-8" />
</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-bold text-accent-primary mb-2">
<ShieldCheck className="w-3.5 h-3.5" />
Night Laboratory Intelligence Workspace
</span>
<h2 className="text-xl sm:text-2xl font-extrabold text-txt-primary tracking-tight">
Trợ Tra Cứu Dược Thư QGVN
</h2>
<p className="text-xs sm:text-sm text-txt-secondary mt-1.5 leading-relaxed max-w-xl mx-auto">
Hệ thống phân tích & tra cứu Dược thư Quốc gia Việt Nam 2018. Đt câu hỏi lâm sàng đ nhận phân tích căn cứ trích dẫn chính xác.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full text-left pt-2">
{STARTER_QUESTIONS.map((q, idx) => {
const Icon = q.icon;
return (
<button
key={idx}
onClick={() => handleSendMessage(q.query)}
className="p-3.5 rounded-2xl border border-border-subtle bg-surface hover:bg-surface-elevated hover:border-border-accent transition-all text-xs flex flex-col gap-1.5 shadow-sm group"
>
<div className="flex items-center justify-between">
<span className="font-bold text-accent-primary flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
{q.category}
</span>
<Zap className="w-3.5 h-3.5 text-txt-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<p className="text-txt-secondary line-clamp-2 m-0 font-normal leading-snug">
{q.query}
</p>
</button>
);
})}
</div>
</div>
);
};
return (
<Card className={cn("flex w-full flex-col overflow-hidden", className)}>
<div className="flex min-h-[32rem] flex-1 flex-col gap-1 overflow-y-auto p-6">
{messages.length === 0 && (
<div className="m-auto max-w-sm text-center text-muted-foreground">
<Pill className="mx-auto mb-2 h-10 w-10 text-primary" aria-hidden="true" />
<p className="mb-1.5 text-lg font-semibold text-foreground">
Hỏi về bất kỳ loại thuốc nào
</p>
<p className="text-[0.95rem]">
dụ: &ldquo;Liều dùng paracetamol cho người lớn?&rdquo; hoặc &ldquo;Chống chỉ
đnh của amoxicillin ?&rdquo;
</p>
<section className={cn("flex flex-col h-full overflow-hidden relative", className)}>
{/* Citation Beam Overlay for Signature Interaction */}
<CitationBeamOverlay activeCitationIndex={activeCitationIndex} />
{/* Messages Workspace List */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
{messages.length === 0 ? (
renderEmptyState()
) : (
messages.map((msg) => (
<ChatBubble
key={msg.id}
message={msg}
onCitationClick={(citation, idx) => onCitationClick?.(citation, idx)}
activeCitationIndex={activeCitationIndex}
onRetry={() => handleSendMessage(msg.content)}
/>
))
)}
{/* Loading Indicator */}
{isLoading && (
<div className="flex items-center gap-3 p-4 rounded-2xl border border-border-subtle bg-surface max-w-md animate-pulse">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<Pill className="h-4 w-4 animate-spin" />
</div>
<div>
<p className="text-xs font-bold text-txt-primary">Đang truy xuất Dược thư QGVN 2018...</p>
<p className="text-[0.68rem] text-txt-muted">Đang phân tích chuyên luận & xác thực Entailment</p>
</div>
</div>
)}
{messages.map((message) => (
<div key={message.id}>
<ChatBubble message={message} />
{message.citations && message.citations.length > 0 && (
<div className="mb-4 mt-1.5 flex flex-wrap">
{message.citations.map((citation) => (
<CitationCard
key={citation.drugName}
citation={citation}
onClick={onCitationClick ? () => onCitationClick(citation) : undefined}
/>
))}
</div>
)}
{/* Error Notification */}
{error && (
<div className="flex items-center justify-between gap-2 p-3.5 rounded-2xl border border-status-danger/40 bg-status-danger-bg text-status-danger text-xs">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
<button
onClick={() => setError(null)}
className="font-bold underline text-[0.7rem]"
>
Đóng
</button>
</div>
))}
{isSending && <TypingIndicator />}
)}
<div ref={messagesEndRef} />
</div>
<form className="flex gap-2.5 border-t bg-muted/40 p-4" onSubmit={handleSubmit}>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Hỏi về một loại thuốc..."
disabled={isSending}
aria-label="Nhập câu hỏi"
/>
<Button type="submit" disabled={isSending}>
<Send className="h-4 w-4" aria-hidden="true" />
{isSending ? "Đang gửi" : "Gửi"}
</Button>
</form>
</Card>
{/* Fixed Composer Bottom Bar */}
<div className="p-3 sm:p-4 border-t border-border-subtle bg-surface-elevated/60 backdrop-blur-md">
<Composer onSubmit={handleSendMessage} isLoading={isLoading} onStop={handleStop} />
</div>
</section>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Send, Square, Sparkles, Pill, Search, Command } from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface ComposerProps {
onSubmit: (query: string) => void;
isLoading?: boolean;
onStop?: () => void;
initialValue?: string;
className?: string;
}
const SAMPLE_SUGGESTIONS = [
"Liều dùng Paracetamol người lớn và trẻ em theo cân nặng",
"Chống chỉ định và tác dụng không mong muốn của Amoxicillin",
"Tương tác thuốc giữa Metformin và thuốc cản quang",
"Thận trọng khi dùng Aspirin cho bệnh nhân loét dạ dày",
"Hướng dẫn liều dùng Ibuprofen và giới hạn tối đa ngày",
];
export function Composer({
onSubmit,
isLoading = false,
onStop,
initialValue = "",
className,
}: ComposerProps) {
const [value, setValue] = useState(initialValue);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (initialValue) {
setValue(initialValue);
}
}, [initialValue]);
// Fetch suggestions from API route when query length > 1
useEffect(() => {
const term = value.trim();
if (term.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`);
if (res.ok) {
const data = await res.json();
if (data?.suggestions && Array.isArray(data.suggestions) && data.suggestions.length > 0) {
setSuggestions(data.suggestions);
setShowSuggestions(true);
return;
}
}
// Fallback filter local suggestions
const filtered = SAMPLE_SUGGESTIONS.filter((s) =>
s.toLowerCase().includes(term.toLowerCase())
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} catch {
setSuggestions([]);
setShowSuggestions(false);
}
}, 200);
return () => clearTimeout(timer);
}, [value]);
// Close suggestions on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setShowSuggestions(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || isLoading) return;
onSubmit(trimmed);
setValue("");
setSuggestions([]);
setShowSuggestions(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
return;
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
setValue(suggestions[selectedIndex]);
setShowSuggestions(false);
setSelectedIndex(-1);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className={cn("relative w-full max-w-4xl mx-auto", className)}>
{/* Autocomplete Suggestions Dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div
ref={dropdownRef}
className="absolute bottom-full mb-2 left-0 right-0 rounded-2xl border border-border-subtle bg-surface p-2 shadow-elevated backdrop-blur-xl z-40 animate-slide-up"
>
<div className="px-3 py-1 mb-1 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
<Search className="w-3 h-3 text-accent-primary" />
<span>Gợi ý tra cứu Dược thư</span>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{suggestions.map((item, idx) => (
<button
key={idx}
onClick={() => {
setValue(item);
setShowSuggestions(false);
inputRef.current?.focus();
}}
className={cn(
"w-full text-left px-3 py-2 rounded-xl text-xs flex items-center justify-between transition-colors",
selectedIndex === idx
? "bg-accent-soft text-accent-primary font-semibold"
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
)}
>
<span className="truncate pr-2">{item}</span>
<Pill className="w-3.5 h-3.5 text-accent-primary shrink-0 opacity-70" />
</button>
))}
</div>
</div>
)}
{/* Main Composer Box */}
<div className="relative flex flex-col rounded-3xl border border-border-subtle bg-surface p-2 shadow-surface transition-all focus-within:border-border-accent focus-within:shadow-elevated glass-panel">
<textarea
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nhập tên thuốc hoặc thuộc tính cần tra (Ví dụ: Liều dùng Paracetamol, Chống chỉ định Amoxicillin...)"
rows={2}
className="w-full resize-none bg-transparent px-3 py-2 text-sm text-txt-primary placeholder:text-txt-muted focus:outline-none"
/>
<div className="flex items-center justify-between gap-2 pt-2 px-2 border-t border-border-subtle/50">
<div className="flex items-center gap-1.5 text-[0.7rem] text-txt-muted">
<Command className="w-3 h-3" />
<span className="hidden sm:inline">Nhấn Enter đ gửi Shift+Enter đ xuống dòng</span>
</div>
<div className="flex items-center gap-2">
{isLoading ? (
<button
onClick={onStop}
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-status-danger text-txt-inverse text-xs font-bold shadow-sm hover:opacity-90 transition-opacity"
>
<Square className="w-3.5 h-3.5 fill-current" />
<span>Dừng</span>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!value.trim()}
type="button"
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-bold transition-all shadow-sm",
value.trim()
? "bg-accent-primary text-txt-inverse hover:bg-accent-hover"
: "bg-surface-elevated text-txt-muted cursor-not-allowed"
)}
>
<span>Gửi tra cứu</span>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import React from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { CitationCard } from "@duoc-thu/ui";
import { BookOpen, X, ShieldCheck, Layers, FileSearch } from "lucide-react";
import { cn } from "@duoc-thu/ui";
interface EvidencePanelProps {
citations: Citation[];
activeCitationIndex: number | null;
onSelectCitation: (citation: Citation, index: number) => void;
onClose?: () => void;
className?: string;
}
export function EvidencePanel({
citations,
activeCitationIndex,
onSelectCitation,
onClose,
className,
}: EvidencePanelProps) {
return (
<aside
className={cn(
"flex flex-col border-l border-border-subtle bg-surface w-80 lg:w-96 shrink-0 h-full overflow-hidden transition-all z-20",
className
)}
>
{/* Evidence Header */}
<header className="p-4 border-b border-border-subtle flex items-center justify-between gap-2 bg-surface-elevated/80 backdrop-blur-md">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent-soft text-accent-primary">
<FileSearch className="h-4 w-4" />
</div>
<div>
<h3 className="m-0 text-xs font-bold text-txt-primary flex items-center gap-1.5">
<span>Bằng Chứng Dược Thư</span>
<span className="rounded-full bg-accent-primary px-2 py-0.5 text-[0.65rem] font-extrabold text-txt-inverse">
{citations.length}
</span>
</h3>
<p className="m-0 text-[0.68rem] text-txt-muted">
Căn cứ chính thức Dược thư QGVN 2018
</p>
</div>
</div>
{onClose && (
<button
onClick={onClose}
aria-label="Đóng thanh bằng chứng"
className="p-1.5 rounded-xl text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</header>
{/* Citations List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{citations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center p-6 text-txt-muted space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-elevated border border-border-subtle text-txt-muted">
<BookOpen className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-semibold text-txt-primary">Chưa trích dẫn nguồn</p>
<p className="text-[0.72rem] text-txt-muted mt-1 leading-relaxed">
Khi đt câu hỏi tra cứu, các trích dẫn chuyên luận kèm số trang in Dược thư 2018 sẽ hiển thị tại đây.
</p>
</div>
</div>
) : (
citations.map((citation, idx) => {
const citationIndex = idx + 1;
const isActive = activeCitationIndex === citationIndex;
return (
<CitationCard
key={idx}
citation={citation}
index={citationIndex}
isActive={isActive}
onSelect={() => onSelectCitation(citation, citationIndex)}
/>
);
})
)}
</div>
{/* Footnote Metadata */}
<footer className="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center justify-between">
<div className="flex items-center gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 text-status-success" />
<span>Xác thực bởi Entailment Engine</span>
</div>
<span className="font-semibold text-txt-secondary">DTQGVN 2018</span>
</footer>
</aside>
);
}
+10 -7
View File
@@ -2,32 +2,35 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MessageSquare, FileSearch } from "lucide-react";
import { cn } from "@duoc-thu/ui";
const TABS = [
{ href: "/", label: "Trò chuyện" },
{ href: "/tra-cuu", label: "Tra cứu cùng PDF" },
{ href: "/", label: "Trò chuyện AI", icon: MessageSquare },
{ href: "/tra-cuu", label: "Tra cứu Dược thư", icon: FileSearch },
];
export function NavTabs() {
const pathname = usePathname();
return (
<nav className="flex gap-1" aria-label="Chuyển chế độ">
<nav className="flex items-center gap-1 rounded-full border border-border-subtle bg-surface p-1 shadow-sm" aria-label="Chuyển chế độ">
{TABS.map((tab) => {
const isActive = pathname === tab.href;
const Icon = tab.icon;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all duration-200",
isActive
? "bg-white/20 text-primary-foreground"
: "text-primary-foreground/70 hover:bg-white/10 hover:text-primary-foreground"
? "bg-accent-soft text-accent-primary border border-border-accent/40 shadow-sm"
: "text-txt-muted hover:bg-surface-hover hover:text-txt-primary"
)}
>
{tab.label}
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-accent-primary" : "text-txt-muted")} />
<span>{tab.label}</span>
</Link>
);
})}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import React, { useState } from "react";
import {
Plus,
MessageSquare,
Trash2,
BookOpen,
Pill,
Search,
ChevronLeft,
ChevronRight,
ShieldCheck,
Zap,
} from "lucide-react";
import { cn } from "@duoc-thu/ui";
export interface ChatSession {
id: string;
title: string;
updatedAt: string;
}
interface SidebarProps {
currentSessionId: string;
sessions: ChatSession[];
onSelectSession: (id: string) => void;
onNewChat: () => void;
onDeleteSession?: (id: string) => void;
onQuickQuery: (query: string) => void;
className?: string;
}
const QUICK_PROMPTS = [
{ drug: "Paracetamol", label: "Liều dùng Paracetamol người lớn & trẻ em" },
{ drug: "Amoxicillin", label: "Chống chỉ định & Thận trọng khi dùng Amoxicillin" },
{ drug: "Metformin", label: "Liều lượng & Tương tác thuốc Metformin" },
{ drug: "Aspirin", label: "Chỉ định & Tác dụng không mong muốn của Aspirin" },
{ drug: "Ibuprofen", label: "Liều dùng Ibuprofen theo trọng lượng cơ thể" },
];
export function Sidebar({
currentSessionId,
sessions,
onSelectSession,
onNewChat,
onDeleteSession,
onQuickQuery,
className,
}: SidebarProps) {
const [searchTerm, setSearchTerm] = useState("");
const [isCollapsed, setIsCollapsed] = useState(false);
const filteredSessions = sessions.filter((s) =>
s.title.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isCollapsed) {
return (
<aside className="flex flex-col items-center py-4 px-2 border-r border-border-subtle bg-surface w-14 shrink-0 transition-all z-20">
<button
onClick={() => setIsCollapsed(false)}
title="Mở rộng danh mục"
className="p-2 rounded-xl text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors mb-4"
>
<ChevronRight className="w-5 h-5" />
</button>
<button
onClick={onNewChat}
title="Cuộc trò chuyện mới"
className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-primary text-txt-inverse shadow-sm hover:bg-accent-hover transition-all mb-4"
>
<Plus className="w-5 h-5" />
</button>
<div className="flex-1 flex flex-col gap-2 w-full items-center overflow-y-auto">
{sessions.map((s) => (
<button
key={s.id}
onClick={() => onSelectSession(s.id)}
title={s.title}
className={cn(
"h-9 w-9 flex items-center justify-center rounded-xl transition-colors text-xs font-bold",
currentSessionId === s.id
? "bg-accent-soft text-accent-primary border border-border-accent"
: "text-txt-muted hover:bg-surface-hover hover:text-txt-primary"
)}
>
<MessageSquare className="w-4 h-4" />
</button>
))}
</div>
</aside>
);
}
return (
<aside
className={cn(
"flex flex-col border-r border-border-subtle bg-surface w-72 shrink-0 transition-all z-20 h-full overflow-hidden",
className
)}
>
{/* Sidebar Header */}
<div className="p-3.5 border-b border-border-subtle flex items-center justify-between gap-2">
<button
onClick={onNewChat}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-accent-primary text-txt-inverse font-semibold text-xs shadow-sm hover:bg-accent-hover transition-all"
>
<Plus className="w-4 h-4" />
<span>Tạo phiên tra cứu mới</span>
</button>
<button
onClick={() => setIsCollapsed(true)}
title="Thu gọn"
className="p-2 rounded-xl text-txt-muted hover:bg-surface-hover hover:text-txt-primary transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
{/* Search sessions */}
<div className="px-3.5 py-2.5 border-b border-border-subtle">
<div className="relative flex items-center">
<Search className="w-3.5 h-3.5 absolute left-3 text-txt-muted pointer-events-none" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Tìm lịch sử tra cứu..."
className="w-full pl-8 pr-3 py-1.5 rounded-xl border border-border-subtle bg-surface-elevated text-txt-primary placeholder:text-txt-muted text-xs focus:outline-none focus:border-border-accent transition-colors"
/>
</div>
</div>
{/* Sessions List */}
<div className="flex-1 overflow-y-auto px-2 py-3 space-y-1">
<div className="px-2 pb-1.5 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center justify-between">
<span>Phiên tra cứu gần đây</span>
<span className="font-semibold text-accent-primary">{filteredSessions.length}</span>
</div>
{filteredSessions.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-txt-muted italic">
Chưa lịch sử tra cứu nào
</div>
) : (
filteredSessions.map((session) => {
const isActive = session.id === currentSessionId;
return (
<div
key={session.id}
onClick={() => onSelectSession(session.id)}
className={cn(
"group relative flex items-center justify-between gap-2 p-2.5 rounded-xl text-xs transition-all cursor-pointer select-none",
isActive
? "bg-accent-soft/40 text-accent-primary font-semibold border border-border-accent/40"
: "text-txt-secondary hover:bg-surface-hover hover:text-txt-primary"
)}
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare
className={cn("w-3.5 h-3.5 shrink-0", isActive ? "text-accent-primary" : "text-txt-muted")}
/>
<span className="truncate">{session.title}</span>
</div>
{onDeleteSession && (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
title="Xóa phiên này"
className="opacity-0 group-hover:opacity-100 p-1 text-txt-muted hover:text-status-danger transition-opacity"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})
)}
{/* Quick Prompts Section */}
<div className="pt-4 px-2 border-t border-border-subtle mt-4">
<div className="pb-2 text-[0.68rem] font-bold tracking-wider text-txt-muted uppercase flex items-center gap-1">
<Zap className="w-3 h-3 text-status-warning" />
<span>Mẫu tra cứu nhanh</span>
</div>
<div className="space-y-1.5">
{QUICK_PROMPTS.map((prompt, idx) => (
<button
key={idx}
onClick={() => onQuickQuery(prompt.label)}
className="w-full text-left p-2 rounded-xl bg-surface-elevated hover:bg-surface-hover border border-border-subtle text-txt-secondary hover:text-txt-primary text-[0.72rem] leading-snug transition-all flex items-center justify-between group"
>
<span className="truncate pr-1">{prompt.label}</span>
<Pill className="w-3 h-3 text-accent-primary shrink-0 opacity-70 group-hover:opacity-100" />
</button>
))}
</div>
</div>
</div>
{/* System Stats Footer */}
<div className="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center justify-between">
<div className="flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 text-accent-primary" />
<span>Dược thư QGVN 2018</span>
</div>
<span className="font-semibold text-accent-primary">684 Chuyên luận</span>
</div>
</aside>
);
}