Remove corpus counts from chat chrome
This commit is contained in:
@@ -19,7 +19,10 @@ import { cn } from "@duoc-thu/ui";
|
||||
|
||||
interface ChatPanelProps {
|
||||
sessionId: string;
|
||||
messages: ChatMessage[];
|
||||
onMessagesChange: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
initialQuery?: string;
|
||||
initialQueryToken?: number;
|
||||
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
|
||||
onCitationsLoaded?: (citations: Citation[]) => void;
|
||||
activeCitationIndex?: number | null;
|
||||
@@ -28,42 +31,45 @@ interface ChatPanelProps {
|
||||
|
||||
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?",
|
||||
category: "Chỉ Định",
|
||||
query: "Levetiracetam được chỉ định trong những trường hợp nào?",
|
||||
icon: Pill,
|
||||
},
|
||||
{
|
||||
category: "Chống Chỉ Định",
|
||||
query: "Chống chỉ định tuyệt đối và tương đối của Amoxicillin là gì?",
|
||||
query: "Chống chỉ định của Metformin 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?",
|
||||
category: "ADR Theo Tần Suất",
|
||||
query: "Tác dụng không mong muốn của Zolpidem là gì?",
|
||||
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?",
|
||||
category: "Thời Kỳ Mang Thai",
|
||||
query: "Có thể dùng Fluoxetin trong thời kỳ mang thai không?",
|
||||
icon: Zap,
|
||||
},
|
||||
];
|
||||
|
||||
export function ChatPanel({
|
||||
sessionId,
|
||||
messages,
|
||||
onMessagesChange: setMessages,
|
||||
initialQuery,
|
||||
initialQueryToken,
|
||||
onCitationClick,
|
||||
onCitationsLoaded,
|
||||
activeCitationIndex = null,
|
||||
className,
|
||||
}: ChatPanelProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const initialQuerySentRef = useRef<string | undefined>(undefined);
|
||||
const initialQuerySentRef = useRef<number | undefined>(undefined);
|
||||
const stopRequestedRef = useRef(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -87,8 +93,12 @@ export function ChatPanel({
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setIsLoading(true);
|
||||
stopRequestedRef.current = false;
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
}, 25_000);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
@@ -115,10 +125,16 @@ export function ChatPanel({
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") {
|
||||
setError(
|
||||
stopRequestedRef.current
|
||||
? "Đã dừng chờ trên giao diện. Tác vụ đang chạy có thể cần vài giây để kết thúc an toàn."
|
||||
: "Yêu cầu vượt quá 25 giây và đã được dừng. Vui lòng thử lại với câu hỏi cụ thể hơn."
|
||||
);
|
||||
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 {
|
||||
window.clearTimeout(timeoutId);
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
@@ -126,12 +142,15 @@ export function ChatPanel({
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
stopRequestedRef.current = true;
|
||||
abortControllerRef.current.abort();
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortControllerRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against firing twice for the same query: React 18 Strict Mode
|
||||
// (dev only) runs this effect setup twice on mount, and with no guard
|
||||
@@ -140,12 +159,16 @@ export function ChatPanel({
|
||||
// muốn của Aspirin" turns in the trace). The ref persists across the
|
||||
// Strict Mode replay, so the second invocation for the same
|
||||
// `initialQuery` is a no-op; a genuinely new query still sends once.
|
||||
if (initialQuery && initialQuerySentRef.current !== initialQuery) {
|
||||
initialQuerySentRef.current = initialQuery;
|
||||
if (
|
||||
initialQuery &&
|
||||
initialQueryToken !== undefined &&
|
||||
initialQuerySentRef.current !== initialQueryToken
|
||||
) {
|
||||
initialQuerySentRef.current = initialQueryToken;
|
||||
handleSendMessage(initialQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialQuery]);
|
||||
}, [initialQuery, initialQueryToken]);
|
||||
|
||||
// Empty state renderer per theme
|
||||
const renderEmptyState = () => {
|
||||
@@ -165,7 +188,7 @@ export function ChatPanel({
|
||||
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.
|
||||
Tra cứu 684 chuyên luận Dược thư Quốc gia Việt Nam 2018 với căn cứ theo trang in. Khi cần, bác sĩ có thể tiếp tục trao đổi để làm rõ dữ kiện và đối chiếu với bối cảnh lâm sàng.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -321,7 +344,13 @@ export function ChatPanel({
|
||||
}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
onRetry={retryQuery ? () => handleSendMessage(retryQuery) : undefined}
|
||||
onQuickReply={(text) => handleSendMessage(text)}
|
||||
onQuickReply={
|
||||
msgIdx === messages.length - 1 &&
|
||||
msg.decision === "clarify" &&
|
||||
!isLoading
|
||||
? (text) => handleSendMessage(text)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -12,14 +12,6 @@ interface ComposerProps {
|
||||
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,
|
||||
@@ -76,13 +68,9 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback filter local suggestions
|
||||
const filtered = SAMPLE_SUGGESTIONS.filter((s) =>
|
||||
s.toLowerCase().includes(term.toLowerCase())
|
||||
);
|
||||
if (cancelled) return;
|
||||
setSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSuggestions([]);
|
||||
@@ -122,6 +110,15 @@ export function Composer({
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const applySuggestion = (suggestion: string) => {
|
||||
// Keep the clinical intent already typed and replace only the unfinished
|
||||
// final token: "liều para" -> "liều Paracetamol", not "Paracetamol".
|
||||
const prefix = value.match(/^([\s\S]*\s)[^\s]*$/)?.[1] ?? "";
|
||||
setValue(`${prefix}${suggestion}`);
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showSuggestions && suggestions.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -136,9 +133,7 @@ export function Composer({
|
||||
}
|
||||
if (e.key === "Enter" && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
setValue(suggestions[selectedIndex]);
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
applySuggestion(suggestions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -166,8 +161,7 @@ export function Composer({
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setValue(item);
|
||||
setShowSuggestions(false);
|
||||
applySuggestion(item);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
@@ -32,11 +32,31 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
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ể" },
|
||||
{
|
||||
drug: "Levetiracetam",
|
||||
label: "Chỉ định của Levetiracetam",
|
||||
query: "Levetiracetam được chỉ định trong những trường hợp nào?",
|
||||
},
|
||||
{
|
||||
drug: "Metformin",
|
||||
label: "Chống chỉ định của Metformin",
|
||||
query: "Chống chỉ định của Metformin là gì?",
|
||||
},
|
||||
{
|
||||
drug: "Zolpidem",
|
||||
label: "ADR Zolpidem theo tần suất",
|
||||
query: "Tác dụng không mong muốn của Zolpidem là gì?",
|
||||
},
|
||||
{
|
||||
drug: "Fluoxetin",
|
||||
label: "Fluoxetin trong thời kỳ mang thai",
|
||||
query: "Có thể dùng Fluoxetin trong thời kỳ mang thai không?",
|
||||
},
|
||||
{
|
||||
drug: "Danazol",
|
||||
label: "Tương tác thuốc của Danazol",
|
||||
query: "Danazol có những tương tác thuốc nào?",
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
@@ -194,7 +214,7 @@ export function Sidebar({
|
||||
{QUICK_PROMPTS.map((prompt, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onQuickQuery(prompt.label)}
|
||||
onClick={() => onQuickQuery(prompt.query)}
|
||||
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>
|
||||
@@ -206,12 +226,11 @@ export function Sidebar({
|
||||
</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="p-3 border-t border-border-subtle bg-surface-elevated/40 text-[0.68rem] text-txt-muted flex items-center">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
import type { AnswerBlock, AnswerPlan, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
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ĩ.";
|
||||
const API_GATEWAY_URL = process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface RagCitation {
|
||||
chunk_id: string;
|
||||
@@ -29,6 +26,40 @@ interface RagResponse {
|
||||
citations: RagCitation[];
|
||||
generated?: boolean;
|
||||
quick_replies?: string[];
|
||||
blocks?: Array<{
|
||||
title: string;
|
||||
kind: string;
|
||||
claims: Array<{ text: string; source_ids: string[] }>;
|
||||
}>;
|
||||
answer_mode?: "concise" | "normal" | "detailed";
|
||||
answer_plan?: {
|
||||
verbosity: "concise" | "normal" | "detailed";
|
||||
layout: string;
|
||||
reasoning_mode: string;
|
||||
show_heading: boolean;
|
||||
needs_warning: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function toAnswerBlocks(raw: NonNullable<RagResponse["blocks"]>): AnswerBlock[] {
|
||||
return raw.map((block) => ({
|
||||
title: block.title,
|
||||
kind: block.kind,
|
||||
claims: block.claims.map((claim) => ({
|
||||
text: claim.text,
|
||||
sourceIds: claim.source_ids,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function toAnswerPlan(raw: NonNullable<RagResponse["answer_plan"]>): AnswerPlan {
|
||||
return {
|
||||
verbosity: raw.verbosity,
|
||||
layout: raw.layout,
|
||||
reasoningMode: raw.reasoning_mode,
|
||||
showHeading: raw.show_heading,
|
||||
needsWarning: raw.needs_warning,
|
||||
};
|
||||
}
|
||||
|
||||
const REFUSALS: Record<string, string> = {
|
||||
@@ -87,6 +118,8 @@ const REFUSALS: Record<string, string> = {
|
||||
"Hệ thống phát hiện một phần câu trả lời không có trích dẫn nguồn rõ ràng nên đã huỷ để tránh sai sót. Vui lòng thử lại.",
|
||||
unsupported_claim:
|
||||
"Dược thư có nội dung liên quan đến câu hỏi này, nhưng bước đối chiếu lại chưa xác nhận được câu trả lời khớp hoàn toàn với nguồn. Vui lòng thử lại.",
|
||||
incomplete_answer:
|
||||
"Câu trả lời vừa tạo đã bị huỷ vì bước đối chiếu phát hiện còn bỏ sót dữ kiện liên quan trong nguồn. Vui lòng thử lại để hệ thống tạo câu trả lời đầy đủ hơn.",
|
||||
// Kept as the fallback `answer.py` itself falls back to when, for some
|
||||
// reason, none of the specific codes above was set.
|
||||
generation_unavailable:
|
||||
@@ -171,6 +204,12 @@ export async function POST(request: Request) {
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "empty_query" }, { status: 400 });
|
||||
}
|
||||
if (content.length > 4000) {
|
||||
return NextResponse.json({ error: "query_too_long" }, { status: 400 });
|
||||
}
|
||||
if (conversationId && conversationId.length > 128) {
|
||||
return NextResponse.json({ error: "conversation_id_too_long" }, { status: 400 });
|
||||
}
|
||||
|
||||
const correlationId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
@@ -194,6 +233,10 @@ export async function POST(request: Request) {
|
||||
conversation_id: conversationId,
|
||||
}),
|
||||
cache: "no-store",
|
||||
// Propagate a browser disconnect/Stop action to the upstream fetch.
|
||||
// The synchronous Bedrock call already in flight may finish, but this
|
||||
// prevents the BFF itself from keeping an orphaned HTTP request open.
|
||||
signal: request.signal,
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
rag = {
|
||||
@@ -212,7 +255,7 @@ export async function POST(request: Request) {
|
||||
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.",
|
||||
answer: `Không thể kết nối đến AI Service (${API_GATEWAY_URL}). Vui lòng đảm bảo AI Service đã được bật.`,
|
||||
resolved_drug_id: null,
|
||||
citations: [],
|
||||
};
|
||||
@@ -227,23 +270,30 @@ export async function POST(request: Request) {
|
||||
// answer-less case (retrieval abstained with no message to show).
|
||||
const noAnswer = rag.answer === null;
|
||||
const isAbstain = rag.decision === "abstain";
|
||||
const isGroundedAnswer =
|
||||
rag.decision === "answerable" && !noAnswer && rag.citations.length > 0;
|
||||
const message: SendMessageResponse["message"] = {
|
||||
id: rag.trace_id || `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: noAnswer ? (REFUSALS[rag.reason] ?? GENERIC_REFUSAL) : (rag.answer ?? GENERIC_REFUSAL),
|
||||
citations: isAbstain || noAnswer ? [] : toCitations(rag.citations),
|
||||
disclaimer: DISCLAIMER,
|
||||
traceId: rag.trace_id,
|
||||
decision: rag.decision,
|
||||
reason: rag.reason,
|
||||
grounded: !isAbstain && !noAnswer,
|
||||
generated: !isAbstain && !noAnswer ? Boolean(rag.generated) : false,
|
||||
grounded: isGroundedAnswer,
|
||||
generated: isGroundedAnswer ? Boolean(rag.generated) : false,
|
||||
resolvedDrugId: rag.resolved_drug_id ?? undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
quickReplies:
|
||||
rag.decision === "clarify" && rag.quick_replies && rag.quick_replies.length > 0
|
||||
? rag.quick_replies
|
||||
: undefined,
|
||||
blocks:
|
||||
isGroundedAnswer && rag.blocks && rag.blocks.length > 0
|
||||
? toAnswerBlocks(rag.blocks)
|
||||
: undefined,
|
||||
answerMode: rag.answer_mode,
|
||||
answerPlan: rag.answer_plan ? toAnswerPlan(rag.answer_plan) : undefined,
|
||||
};
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
process.env.API_GATEWAY_URL ?? process.env.AI_SERVICE_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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.",
|
||||
description: "Tra cứu 684 chuyên luận Dược thư Quốc gia Việt Nam 2018 với căn cứ theo trang in, hỗ trợ bác sĩ làm rõ dữ kiện và thảo luận theo bối cảnh lâm sàng.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -36,9 +36,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
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>
|
||||
|
||||
|
||||
+47
-23
@@ -1,30 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Citation } from "@duoc-thu/shared-types";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatMessage, 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(),
|
||||
},
|
||||
];
|
||||
function createSessionId() {
|
||||
return `session-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>(INITIAL_SESSIONS);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("session-1");
|
||||
const [queryOverride, setQueryOverride] = useState<string | undefined>();
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||
const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
|
||||
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = createSessionId();
|
||||
setSessions([{ id, title: "Phiên tra cứu mới", updatedAt: new Date().toISOString() }]);
|
||||
setMessagesBySession({ [id]: [] });
|
||||
setCurrentSessionId(id);
|
||||
}, []);
|
||||
|
||||
// Citation & Evidence Panel State
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
@@ -36,15 +35,16 @@ export default function ChatPage() {
|
||||
const [showEvidenceDesktop, setShowEvidenceDesktop] = useState(true);
|
||||
|
||||
const handleNewChat = () => {
|
||||
const newId = `session-${Date.now()}`;
|
||||
const newId = createSessionId();
|
||||
const newSession: ChatSession = {
|
||||
id: newId,
|
||||
title: "Phiên tra cứu mới",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSessions((prev) => [newSession, ...prev]);
|
||||
setMessagesBySession((prev) => ({ ...prev, [newId]: [] }));
|
||||
setCurrentSessionId(newId);
|
||||
setQueryOverride(undefined);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
setShowMobileSidebar(false);
|
||||
@@ -52,12 +52,19 @@ export default function ChatPage() {
|
||||
|
||||
const handleSelectSession = (id: string) => {
|
||||
setCurrentSessionId(id);
|
||||
setQueryOverride(undefined);
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
const handleDeleteSession = (id: string) => {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setMessagesBySession((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
if (currentSessionId === id) {
|
||||
const remaining = sessions.filter((s) => s.id !== id);
|
||||
if (remaining.length > 0) {
|
||||
@@ -69,10 +76,24 @@ export default function ChatPage() {
|
||||
};
|
||||
|
||||
const handleQuickQuery = (query: string) => {
|
||||
setQueryOverride(query);
|
||||
setQueryOverride({ text: query, token: Date.now() });
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
const currentMessages = messagesBySession[currentSessionId] ?? [];
|
||||
const setCurrentMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>> = (update) => {
|
||||
const sessionId = currentSessionId;
|
||||
setMessagesBySession((prev) => {
|
||||
const existing = prev[sessionId] ?? [];
|
||||
const next = typeof update === "function" ? update(existing) : update;
|
||||
return { ...prev, [sessionId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (!currentSessionId) {
|
||||
return <div className="flex flex-1 items-center justify-center text-sm text-txt-muted">Đang tạo phiên tra cứu an toàn...</div>;
|
||||
}
|
||||
|
||||
const handleCitationClick = (citation: Citation, index: number, allCitations: Citation[]) => {
|
||||
// Found live 2026-08-07: this used to only set the index into whatever
|
||||
// `citations` array was last loaded (i.e. the MOST RECENT answer's), so
|
||||
@@ -157,9 +178,12 @@ export default function ChatPage() {
|
||||
{/* Desktop Region 2: Primary Answer Workspace */}
|
||||
<main className="flex-1 flex justify-center overflow-hidden relative">
|
||||
<ChatPanel
|
||||
key={`${currentSessionId}-${queryOverride}`}
|
||||
key={currentSessionId}
|
||||
sessionId={currentSessionId}
|
||||
initialQuery={queryOverride}
|
||||
messages={currentMessages}
|
||||
onMessagesChange={setCurrentMessages}
|
||||
initialQuery={queryOverride?.text}
|
||||
initialQueryToken={queryOverride?.token}
|
||||
onCitationClick={handleCitationClick}
|
||||
onCitationsLoaded={handleCitationsLoaded}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookOpen, Bookmark, FileText, Sparkles } from "lucide-react";
|
||||
import type { Citation } from "@duoc-thu/shared-types";
|
||||
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
|
||||
import { ChatPanel } from "../_components/ChatPanel";
|
||||
import { cn } from "@duoc-thu/ui";
|
||||
|
||||
@@ -10,6 +10,12 @@ export default function TraCuuPage() {
|
||||
const [activePage, setActivePage] = useState<number | null>(null);
|
||||
const [activeDrug, setActiveDrug] = useState<string | null>(null);
|
||||
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionId(`lookup-${crypto.randomUUID()}`);
|
||||
}, []);
|
||||
|
||||
function handleCitationClick(citation: Citation) {
|
||||
if (citation.sourcePageRange && citation.sourcePageRange[0]) {
|
||||
@@ -69,11 +75,15 @@ export default function TraCuuPage() {
|
||||
<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}
|
||||
/>
|
||||
{sessionId && (
|
||||
<ChatPanel
|
||||
sessionId={sessionId}
|
||||
messages={messages}
|
||||
onMessagesChange={setMessages}
|
||||
className="flex-1 h-full"
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user