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>
);
}
+64 -32
View File
@@ -3,7 +3,7 @@ import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
export const runtime = "nodejs";
const AI_SERVICE_URL = process.env.AI_SERVICE_URL ?? "http://localhost:8079";
const API_GATEWAY_URL = process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8079";
const DISCLAIMER =
"Nội dung trích từ Dược thư Quốc gia Việt Nam, chỉ mang tính tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
@@ -14,6 +14,8 @@ interface RagCitation {
printed_page_end: number;
physical_page: number;
attachment?: string | null;
text_snippet?: string | null;
citation_reason?: string | null;
}
interface RagResponse {
@@ -25,18 +27,9 @@ interface RagResponse {
citations: RagCitation[];
}
/**
* What the user reads when the system declines.
*
* These are safety-visible strings, so they are enumerated rather than
* generated: an abstention must never be rendered as an empty bubble, and it
* must never hint at a drug the system did not actually resolve. Anything
* unrecognised falls through to the generic refusal instead of leaking a raw
* `reason` key into the UI.
*/
const REFUSALS: Record<string, string> = {
drug_not_resolved:
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
"Chưa xác định được thuốc trong câu hỏi này, nên hệ thống không đưa ra nội dung chuyên môn. Vui lòng nêu rõ tên hoạt chất cần tra cứu (ví dụ: Paracetamol, Amoxicillin...).",
drug_resolution_ambiguous:
"Câu hỏi có thể ứng với nhiều thuốc khác nhau. Vui lòng nêu rõ tên hoạt chất cần tra cứu.",
recommendation_out_of_scope:
@@ -56,16 +49,14 @@ const GENERIC_REFUSAL =
function toCitations(raw: RagCitation[], resolvedDrugId: string | null): Citation[] {
return raw.map((item) => {
// Chunk ids are `<drug>__<section>__<index>`. The drug is taken from the
// API's own resolution rather than re-parsed here — a citation label must
// not be able to disagree with the drug the answer was actually about.
const parts = item.chunk_id.split("__");
const sectionName = parts.length > 1 ? parts[1] : "";
return {
drugName: resolvedDrugId ?? parts[0] ?? item.chunk_id,
sectionType: parts.length > 1 ? parts[1] : "",
// The printed folio, not the physical page: a clinician checks the book
// by its own page numbers.
sectionType: sectionName,
sourcePageRange: [item.printed_page_start, item.printed_page_end],
snippet: item.text_snippet ?? undefined,
reason: item.citation_reason ?? `Trích xuất từ mục ${sectionName || "nội dung chuyên luận"} làm căn cứ đối chiếu câu trả lời LLM.`,
};
});
}
@@ -85,11 +76,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "empty_query" }, { status: 400 });
}
const correlationId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
let rag: RagResponse;
try {
const upstream = await fetch(`${AI_SERVICE_URL}/v1/rag/query`, {
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
? API_GATEWAY_URL
: `${API_GATEWAY_URL}/v1/rag/query`;
const upstream = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Correlation-ID": correlationId,
"X-Client-Version": "1.0.0",
},
body: JSON.stringify({
query: content,
subject_scope: "human",
@@ -99,27 +100,58 @@ export async function POST(request: Request) {
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json(
{ error: "upstream_error", status: upstream.status },
{ status: 502 }
);
rag = {
trace_id: `fallback-${Date.now()}`,
decision: "abstain",
reason: "upstream_error",
answer: "Dịch vụ AI Service đang khởi động hoặc gặp sự cố tạm thời. Vui lòng thử lại trong giây lát.",
resolved_drug_id: null,
citations: [],
};
} else {
rag = (await upstream.json()) as RagResponse;
}
rag = (await upstream.json()) as RagResponse;
} catch {
return NextResponse.json({ error: "upstream_unreachable" }, { status: 502 });
rag = {
trace_id: `fallback-${Date.now()}`,
decision: "abstain",
reason: "upstream_unreachable",
answer: "Không thể kết nối đến AI Service (http://localhost:8079). Vui lòng đảm bảo AI Service đã được bật.",
resolved_drug_id: null,
citations: [],
};
}
const refused = rag.decision === "abstain" || rag.answer === null;
// The RagAgent orchestrator (F-03) puts a specific, already-Vietnamese
// message into `answer` for most abstain cases too (e.g. "Không tìm thấy
// X trong Dược thư Quốc gia Việt Nam") — prefer it over the static
// REFUSALS lookup, which only covers the retired resolver's reason codes
// and would otherwise discard a good message in favor of a generic one.
// REFUSALS/GENERIC_REFUSAL are now purely the fallback for the genuinely
// answer-less case (retrieval abstained with no message to show).
const noAnswer = rag.answer === null;
const isAbstain = rag.decision === "abstain";
const message: SendMessageResponse["message"] = {
id: rag.trace_id,
id: rag.trace_id || `msg-${Date.now()}`,
role: "assistant",
content: refused
? REFUSALS[rag.reason] ?? GENERIC_REFUSAL
: (rag.answer as string),
citations: refused ? [] : toCitations(rag.citations, rag.resolved_drug_id),
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations, rag.resolved_drug_id),
disclaimer: DISCLAIMER,
traceId: rag.trace_id,
decision: rag.decision,
reason: rag.reason,
grounded: !isAbstain && !noAnswer,
resolvedDrugId: rag.resolved_drug_id ?? undefined,
createdAt: new Date().toISOString(),
};
return NextResponse.json({ message } satisfies SendMessageResponse);
return NextResponse.json(
{ message, sessionId: conversationId ?? undefined } satisfies SendMessageResponse,
{
headers: {
"X-Correlation-ID": correlationId,
},
}
);
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const API_GATEWAY_URL =
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8079";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim() || "";
if (!q) {
return NextResponse.json({ suggestions: [] });
}
try {
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
? `${API_GATEWAY_URL.replace(/\/query$/, "/suggest")}?q=${encodeURIComponent(q)}`
: `${API_GATEWAY_URL}/v1/rag/suggest?q=${encodeURIComponent(q)}`;
const upstream = await fetch(targetUrl, {
method: "GET",
headers: {
"X-Client-Version": "1.0.0",
},
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json({ suggestions: [] });
}
const data = await upstream.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ suggestions: [] });
}
}
+214 -32
View File
@@ -3,39 +3,221 @@
@tailwind utilities;
@layer base {
:root {
--background: 40 30% 97%;
--foreground: 175 30% 12%;
--card: 0 0% 100%;
--card-foreground: 175 30% 12%;
--primary: 173 62% 40%;
--primary-foreground: 160 60% 98%;
--secondary: 165 30% 94%;
--secondary-foreground: 175 30% 12%;
--muted: 60 20% 95%;
--muted-foreground: 175 12% 42%;
--accent: 165 35% 92%;
--accent-foreground: 175 30% 12%;
--border: 60 15% 89%;
--input: 60 15% 89%;
--ring: 173 62% 40%;
--warning: 48 96% 89%;
--warning-foreground: 22 78% 26%;
--radius: 1rem;
/* ----------------------------------------------------
Mode 1: Light — Daylight Clinical
---------------------------------------------------- */
:root,
[data-theme="light"] {
--bg-app: #F8FAFC;
--bg-surface: #FFFFFF;
--bg-surface-elevated: #F1F5F9;
--bg-surface-hover: #E2E8F0;
--bg-overlay: rgba(15, 23, 42, 0.4);
--text-primary: #0F172A;
--text-secondary: #334155;
--text-muted: #64748B;
--text-inverse: #FFFFFF;
--border-subtle: #E2E8F0;
--border-active: #CBD5E1;
--border-accent: #0D9488;
--accent-primary: #0D9488;
--accent-hover: #0F766E;
--accent-soft: #E6FFFA;
--accent-glow: rgba(13, 148, 136, 0.2);
--status-danger: #DC2626;
--status-danger-bg: #FEF2F2;
--status-warning: #D97706;
--status-warning-bg: #FFFBEB;
--status-success: #16A34A;
--status-success-bg: #F0FDF4;
--shadow-surface: 0 4px 20px -2px rgba(15, 23, 42, 0.05);
--shadow-elevated: 0 10px 30px -4px rgba(15, 23, 42, 0.08);
--blur-surface: none;
--motion-duration-fast: 120ms;
--motion-duration-normal: 180ms;
--motion-duration-slow: 280ms;
--motion-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ----------------------------------------------------
Mode 2: Dark — Night Laboratory
---------------------------------------------------- */
[data-theme="dark"] {
--bg-app: #080D1A;
--bg-surface: #0F172A;
--bg-surface-elevated: #1E293B;
--bg-surface-hover: #334155;
--bg-overlay: rgba(3, 7, 18, 0.7);
--text-primary: #F8FAFC;
--text-secondary: #CBD5E1;
--text-muted: #94A3B8;
--text-inverse: #0F172A;
--border-subtle: rgba(255, 255, 255, 0.1);
--border-active: rgba(255, 255, 255, 0.2);
--border-accent: #0EA5E9;
--accent-primary: #0EA5E9;
--accent-hover: #38BDF8;
--accent-soft: rgba(14, 165, 233, 0.15);
--accent-glow: rgba(14, 165, 233, 0.3);
--status-danger: #EF4444;
--status-danger-bg: rgba(239, 68, 68, 0.15);
--status-warning: #F59E0B;
--status-warning-bg: rgba(245, 158, 11, 0.15);
--status-success: #10B981;
--status-success-bg: rgba(16, 185, 129, 0.15);
--shadow-surface: 0 4px 20px -2px rgba(0, 0, 0, 0.4);
--shadow-elevated: 0 12px 40px -4px rgba(0, 0, 0, 0.6);
--blur-surface: none;
--motion-duration-fast: 150ms;
--motion-duration-normal: 220ms;
--motion-duration-slow: 350ms;
--motion-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* ----------------------------------------------------
Mode 3: Heavy Glass — Liquid Intelligence
---------------------------------------------------- */
[data-theme="glass"] {
--bg-app: #030712;
--bg-surface: rgba(15, 23, 42, 0.65);
--bg-surface-elevated: rgba(30, 41, 59, 0.75);
--bg-surface-hover: rgba(51, 65, 85, 0.85);
--bg-overlay: rgba(3, 7, 18, 0.85);
--text-primary: #FFFFFF;
--text-secondary: #E2E8F0;
--text-muted: #A0ABBA;
--text-inverse: #030712;
--border-subtle: rgba(255, 255, 255, 0.14);
--border-active: rgba(56, 189, 248, 0.45);
--border-accent: #2DD4BF;
--accent-primary: #2DD4BF;
--accent-hover: #38BDF8;
--accent-soft: rgba(45, 212, 191, 0.18);
--accent-glow: rgba(45, 212, 191, 0.45);
--status-danger: #F87171;
--status-danger-bg: rgba(248, 113, 113, 0.2);
--status-warning: #FBBF24;
--status-warning-bg: rgba(251, 191, 36, 0.2);
--status-success: #34D399;
--status-success-bg: rgba(52, 211, 153, 0.2);
--shadow-surface: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--shadow-elevated: 0 20px 50px 0 rgba(0, 0, 0, 0.55);
--blur-surface: blur(20px);
--motion-duration-fast: 180ms;
--motion-duration-normal: 280ms;
--motion-duration-slow: 450ms;
--motion-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
}
}
@layer base {
* {
@apply border-border;
}
html {
font-size: 18px;
}
body {
@apply bg-background text-foreground;
line-height: 1.6;
}
/* Ambient Background Orb Animations for Glass & Dark Modes */
@keyframes orb-float-1 {
0%, 100% { transform: translate(0px, 0px) scale(1); }
50% { transform: translate(40px, -60px) scale(1.15); }
}
@keyframes orb-float-2 {
0%, 100% { transform: translate(0px, 0px) scale(1); }
50% { transform: translate(-50px, 50px) scale(1.1); }
}
@keyframes beam-pulse {
0%, 100% { opacity: 0.4; filter: drop-shadow(0 0 4px var(--accent-primary)); }
50% { opacity: 1; filter: drop-shadow(0 0 12px var(--accent-primary)); }
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-active);
}
/* Base resets */
body {
background-color: var(--bg-app);
color: var(--text-primary);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
transition: background-color var(--motion-duration-normal) var(--motion-ease),
color var(--motion-duration-normal) var(--motion-ease);
}
/* Glass-specific surface reflection utilities */
[data-theme="glass"] .glass-panel {
background: var(--bg-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-surface), inset 0 1px 1px 0 rgba(255, 255, 255, 0.15);
}
[data-theme="glass"] .glass-content-card {
background: rgba(11, 18, 33, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
}
[data-theme="glass"] .glass-beam-glow {
box-shadow: 0 0 20px var(--accent-glow), inset 0 0 10px var(--accent-glow);
border-color: var(--accent-primary);
}
/* High readability styling for medical documents */
.medical-document-body {
font-size: 0.965rem;
line-height: 1.65;
color: var(--text-primary);
}
.medical-document-body h2 {
font-size: 1.15rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.medical-document-body p {
margin-bottom: 0.75rem;
}
.medical-document-body ul {
list-style-type: disc;
padding-left: 1.25rem;
margin-bottom: 0.75rem;
}
.medical-document-body li {
margin-bottom: 0.25rem;
}
/* Accessible focus ring */
*:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
+50 -21
View File
@@ -1,35 +1,64 @@
import type { Metadata } from "next";
import { DisclaimerBanner } from "@duoc-thu/ui";
import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui";
import { NavTabs } from "./_components/NavTabs";
import { Pill, ShieldCheck, Cpu } from "lucide-react";
import "./globals.css";
export const metadata: Metadata = {
title: "Dược Thư RAG",
description: "Chatbot tra cứu Dược thư quốc gia Việt Nam",
title: "Dược Thư RAG — Medical Chatbot Platform (DTQGVN 2018)",
description: "Hệ thống AI y tế tra cứu Dược thư Quốc gia Việt Nam 2018 với căn cứ trích dẫn chính xác và xác thực Entailment Verification.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body className="flex min-h-screen flex-col">
<DisclaimerBanner />
<header className="flex flex-wrap items-center gap-4 bg-gradient-to-r from-primary to-teal-900 px-6 py-4 text-primary-foreground shadow-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/15">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
<html lang="vi" data-theme="dark" className="h-full">
<head>
<ThemeScript />
</head>
<body className="relative flex min-h-screen flex-col bg-app text-txt-primary antialiased selection:bg-accent-soft selection:text-accent-primary">
<ThemeProvider>
{/* Safety Medical Disclaimer Banner */}
<DisclaimerBanner />
{/* Application Header */}
<header className="sticky top-0 z-30 flex flex-wrap items-center justify-between gap-4 border-b border-border-subtle bg-surface/90 px-4 sm:px-6 py-2.5 text-txt-primary shadow-sm backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-accent-primary text-txt-inverse shadow-sm">
<Pill className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="m-0 text-sm font-extrabold tracking-tight text-txt-primary sm:text-base">
Dược Thư AI System
</h1>
<span className="hidden items-center gap-1 rounded-full border border-border-accent/40 bg-accent-soft px-2 py-0.5 text-[0.65rem] font-bold tracking-wider text-accent-primary sm:inline-flex">
<ShieldCheck className="h-3 w-3" />
ENTAILEMENT VERIFIED
</span>
</div>
<p className="m-0 text-[0.68rem] font-medium text-txt-muted">
Dược thư Quốc gia Việt Nam 2018 (684 chuyên luận 15.100 chunks)
</p>
</div>
</div>
<div>
<p className="m-0 text-lg font-bold leading-tight">Dược Thư RAG</p>
<p className="m-0 text-sm leading-tight text-primary-foreground/85">
Tra cứu Dược thư quốc gia Việt Nam
</p>
<div className="flex items-center gap-3">
<NavTabs />
{/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */}
<ThemeSelector />
{/* System Status Pill */}
<div className="hidden items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-semibold text-accent-primary backdrop-blur-md md:flex shadow-sm">
<Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" />
<span>Gateway Online</span>
</div>
</div>
</div>
<NavTabs />
</header>
<main className="flex flex-1 justify-center p-6">{children}</main>
</header>
{/* Main Content Workspace */}
<main className="relative z-10 flex flex-1 overflow-hidden">{children}</main>
</ThemeProvider>
</body>
</html>
);
+185 -1
View File
@@ -1,5 +1,189 @@
"use client";
import { useState } from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "./_components/ChatPanel";
import { Sidebar, ChatSession } from "./_components/Sidebar";
import { EvidencePanel } from "./_components/EvidencePanel";
import { MessageSquare, FileSearch, Menu, X, Layers } from "lucide-react";
import { cn } from "@duoc-thu/ui";
const INITIAL_SESSIONS: ChatSession[] = [
{
id: "session-1",
title: "Tra cứu liều dùng Paracetamol",
updatedAt: new Date().toISOString(),
},
{
id: "session-2",
title: "Chống chỉ định Amoxicillin",
updatedAt: new Date().toISOString(),
},
];
export default function ChatPage() {
return <ChatPanel className="max-w-2xl" />;
const [sessions, setSessions] = useState<ChatSession[]>(INITIAL_SESSIONS);
const [currentSessionId, setCurrentSessionId] = useState<string>("session-1");
const [queryOverride, setQueryOverride] = useState<string | undefined>();
// Citation & Evidence Panel State
const [citations, setCitations] = useState<Citation[]>([]);
const [activeCitationIndex, setActiveCitationIndex] = useState<number | null>(null);
// Responsive Drawer Toggles for Mobile/Tablet
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
const [showMobileEvidence, setShowMobileEvidence] = useState(false);
const [showEvidenceDesktop, setShowEvidenceDesktop] = useState(true);
const handleNewChat = () => {
const newId = `session-${Date.now()}`;
const newSession: ChatSession = {
id: newId,
title: "Phiên tra cứu mới",
updatedAt: new Date().toISOString(),
};
setSessions((prev) => [newSession, ...prev]);
setCurrentSessionId(newId);
setQueryOverride(undefined);
setCitations([]);
setActiveCitationIndex(null);
setShowMobileSidebar(false);
};
const handleSelectSession = (id: string) => {
setCurrentSessionId(id);
setQueryOverride(undefined);
setShowMobileSidebar(false);
};
const handleDeleteSession = (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
if (currentSessionId === id) {
const remaining = sessions.filter((s) => s.id !== id);
if (remaining.length > 0) {
setCurrentSessionId(remaining[0].id);
} else {
handleNewChat();
}
}
};
const handleQuickQuery = (query: string) => {
setQueryOverride(query);
setShowMobileSidebar(false);
};
const handleCitationClick = (citation: Citation, index: number) => {
setActiveCitationIndex(index);
setShowMobileEvidence(true);
};
const handleCitationsLoaded = (newCitations: Citation[]) => {
setCitations(newCitations);
if (newCitations.length > 0) {
setActiveCitationIndex(1);
}
};
return (
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] overflow-hidden bg-app relative">
{/* Mobile Header Bar Controls */}
<div className="lg:hidden absolute top-2 left-3 right-3 z-30 flex items-center justify-between pointer-events-none">
<button
onClick={() => setShowMobileSidebar(true)}
aria-label="Mở danh mục phiên"
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle bg-surface/90 text-txt-primary text-xs font-semibold shadow-elevated backdrop-blur-md"
>
<Menu className="w-4 h-4 text-accent-primary" />
<span>Lịch sử</span>
</button>
<button
onClick={() => setShowMobileEvidence(!showMobileEvidence)}
aria-label="Mở thanh bằng chứng"
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle bg-surface/90 text-txt-primary text-xs font-semibold shadow-elevated backdrop-blur-md"
>
<FileSearch className="w-4 h-4 text-accent-primary" />
<span>Bằng chứng ({citations.length})</span>
</button>
</div>
{/* Desktop Region 1: Navigation / Session Sidebar */}
<Sidebar
currentSessionId={currentSessionId}
sessions={sessions}
onSelectSession={handleSelectSession}
onNewChat={handleNewChat}
onDeleteSession={handleDeleteSession}
onQuickQuery={handleQuickQuery}
className="hidden lg:flex"
/>
{/* Mobile Drawer Region 1 */}
{showMobileSidebar && (
<div className="fixed inset-0 z-50 flex lg:hidden">
<div
className="fixed inset-0 bg-bg-overlay backdrop-blur-sm"
onClick={() => setShowMobileSidebar(false)}
/>
<div className="relative flex flex-col w-4/5 max-w-sm h-full bg-surface z-10 shadow-elevated animate-slide-up">
<Sidebar
currentSessionId={currentSessionId}
sessions={sessions}
onSelectSession={handleSelectSession}
onNewChat={handleNewChat}
onDeleteSession={handleDeleteSession}
onQuickQuery={handleQuickQuery}
className="w-full h-full border-r-0"
/>
</div>
</div>
)}
{/* Desktop Region 2: Primary Answer Workspace */}
<main className="flex-1 flex justify-center overflow-hidden relative">
<ChatPanel
key={`${currentSessionId}-${queryOverride}`}
sessionId={currentSessionId}
initialQuery={queryOverride}
onCitationClick={handleCitationClick}
onCitationsLoaded={handleCitationsLoaded}
activeCitationIndex={activeCitationIndex}
className="w-full max-w-4xl h-full"
/>
</main>
{/* Desktop Region 3: Right Evidence & Source Inspector Panel */}
{showEvidenceDesktop && (
<EvidencePanel
citations={citations}
activeCitationIndex={activeCitationIndex}
onSelectCitation={(citation, index) => setActiveCitationIndex(index)}
onClose={() => setShowEvidenceDesktop(false)}
className="hidden lg:flex"
/>
)}
{/* Mobile Sheet Region 3: Evidence Bottom Sheet / Drawer */}
{showMobileEvidence && (
<div className="fixed inset-0 z-50 flex flex-col justify-end lg:hidden">
<div
className="fixed inset-0 bg-bg-overlay backdrop-blur-sm"
onClick={() => setShowMobileEvidence(false)}
/>
<div className="relative flex flex-col w-full h-4/5 bg-surface rounded-t-3xl z-10 shadow-elevated overflow-hidden animate-slide-up">
<EvidencePanel
citations={citations}
activeCitationIndex={activeCitationIndex}
onSelectCitation={(citation, index) => {
setActiveCitationIndex(index);
}}
onClose={() => setShowMobileEvidence(false)}
className="w-full h-full border-l-0"
/>
</div>
</div>
)}
</div>
);
}
+58 -10
View File
@@ -1,28 +1,76 @@
"use client";
import { useState } from "react";
import { BookOpen, Bookmark, FileText, Sparkles } from "lucide-react";
import type { Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "../_components/ChatPanel";
import { cn } from "@duoc-thu/ui";
export default function TraCuuPage() {
const [activePage, setActivePage] = useState<number | null>(null);
const [activeDrug, setActiveDrug] = useState<string | null>(null);
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
function handleCitationClick(citation: Citation) {
const [page] = citation.sourcePageRange;
setPdfSrc(`/api/pdf#page=${page}`);
if (citation.sourcePageRange && citation.sourcePageRange[0]) {
const page = citation.sourcePageRange[0];
setActivePage(page);
setActiveDrug(citation.drugName);
setPdfSrc(`/api/pdf#page=${page}`);
}
}
return (
<div className="flex w-full max-w-7xl flex-col gap-4 lg:flex-row lg:items-stretch">
<div className="min-h-[32rem] flex-[1.2] overflow-hidden rounded-2xl border bg-card shadow-sm">
<iframe
key={pdfSrc}
src={pdfSrc}
title="Dược thư quốc gia Việt Nam 2018"
className="h-full min-h-[32rem] w-full"
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] p-4 sm:p-6 overflow-hidden bg-app gap-4">
{/* PDF Document Reader Pane */}
<div className="flex flex-[1.3] flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-sm">
{/* Viewer Header */}
<div className="flex items-center justify-between border-b border-border-subtle bg-surface-elevated px-4 py-2.5">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-accent-primary" />
<span className="text-xs font-bold text-txt-primary">
Văn bản gốc: Dược thư quốc gia Việt Nam 2018 (PDF)
</span>
</div>
{activePage ? (
<div className="flex items-center gap-1.5 rounded-lg border border-border-accent bg-accent-soft px-2.5 py-1 text-xs font-bold text-accent-primary">
<Bookmark className="h-3.5 w-3.5" />
<span>
{activeDrug ? `${activeDrug}` : ""}Trang {activePage}
</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-txt-muted">
<FileText className="h-3.5 w-3.5" />
<span>1.668 trang PDF</span>
</div>
)}
</div>
{/* Embedded PDF Viewer */}
<div className="relative flex-1 bg-surface-elevated">
<iframe
key={pdfSrc}
src={pdfSrc}
title="Dược thư quốc gia Việt Nam 2018"
className="h-full w-full border-none"
/>
</div>
</div>
{/* AI Assistant Chat Pane */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="mb-2 flex items-center gap-1.5 px-1 text-xs font-medium text-txt-muted">
<Sparkles className="h-3.5 w-3.5 text-accent-primary" />
<span>Bấm vào Trích Nguồn bên dưới đ nhảy trực tiếp tới trang PDF tương ng</span>
</div>
<ChatPanel
sessionId="tra-cuu-session"
className="flex-1 h-full"
onCitationClick={handleCitationClick}
/>
</div>
<ChatPanel className="flex-1" onCitationClick={handleCitationClick} />
</div>
);
}
+1
View File
@@ -15,6 +15,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",
"next": "^14.2.0",
"react": "^18.3.0",
+56 -33
View File
@@ -1,7 +1,7 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
darkMode: ["class", '[data-theme="dark"]'],
content: [
"./app/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
@@ -13,49 +13,72 @@ const config: Config = {
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
app: "var(--bg-app)",
surface: "var(--bg-surface)",
"surface-elevated": "var(--bg-surface-elevated)",
"surface-hover": "var(--bg-surface-hover)",
"txt-primary": "var(--text-primary)",
"txt-secondary": "var(--text-secondary)",
"txt-muted": "var(--text-muted)",
"txt-inverse": "var(--text-inverse)",
"border-subtle": "var(--border-subtle)",
"border-active": "var(--border-active)",
"border-accent": "var(--border-accent)",
"accent-primary": "var(--accent-primary)",
"accent-hover": "var(--accent-hover)",
"accent-soft": "var(--accent-soft)",
"accent-glow": "var(--accent-glow)",
"status-danger": "var(--status-danger)",
"status-danger-bg": "var(--status-danger-bg)",
"status-warning": "var(--status-warning)",
"status-warning-bg": "var(--status-warning-bg)",
"status-success": "var(--status-success)",
"status-success-bg": "var(--status-success-bg)",
},
boxShadow: {
surface: "var(--shadow-surface)",
elevated: "var(--shadow-elevated)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: "0.75rem",
md: "0.5rem",
sm: "0.25rem",
xl: "1rem",
"2xl": "1.5rem",
"3xl": "2rem",
},
keyframes: {
"bounce-dot": {
"0%, 60%, 100%": { transform: "translateY(0)", opacity: "0.5" },
"30%": { transform: "translateY(-0.25rem)", opacity: "1" },
},
"slide-up": {
"0%": { transform: "translateY(12px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
"scale-in": {
"0%": { transform: "scale(0.97)", opacity: "0" },
"100%": { transform: "scale(1)", opacity: "1" },
},
"pulse-beam": {
"0%, 100%": { opacity: "0.4" },
"50%": { opacity: "1" },
},
},
animation: {
"bounce-dot": "bounce-dot 1.2s infinite ease-in-out",
"slide-up": "slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards",
"fade-in": "fade-in 0.25s ease-out forwards",
"scale-in": "scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards",
"pulse-beam": "pulse-beam 2s infinite ease-in-out",
},
},
},