Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -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ợ Lý 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ó 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]">
|
||||
Ví dụ: “Liều dùng paracetamol cho người lớn?” hoặc “Chống chỉ
|
||||
định của amoxicillin là gì?”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user