Add read-only production runtime audit
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import type { ChatMessage, Citation, SendMessageResponse } from "@duoc-thu/shared-types";
|
||||
import type {
|
||||
AnswerBlock,
|
||||
ChatMessage,
|
||||
Citation,
|
||||
DrugSectionOption,
|
||||
MonographPickerState,
|
||||
SendMessageResponse,
|
||||
} from "@duoc-thu/shared-types";
|
||||
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
|
||||
import { Composer } from "./Composer";
|
||||
import { AnswerFeedback } from "./AnswerFeedback";
|
||||
@@ -27,9 +34,36 @@ interface ChatPanelProps {
|
||||
onCitationClick?: (citation: Citation, index: number, allCitations: Citation[]) => void;
|
||||
onCitationsLoaded?: (citations: Citation[]) => void;
|
||||
activeCitationIndex?: number | null;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onMonographChange?: (picker: MonographPickerState | null) => void;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SectionTextResponse {
|
||||
drug_id: string;
|
||||
section_key: string;
|
||||
section_title: string | null;
|
||||
parts: Array<{
|
||||
part_index: number | null;
|
||||
text: string;
|
||||
is_quarantined: boolean;
|
||||
printed_page_start: number | null;
|
||||
printed_page_end: number | null;
|
||||
physical_page: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MONOGRAPH_DISCLAIMER =
|
||||
"Nội dung nguyên văn được lấy từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
|
||||
|
||||
const QUICK_SECTION_KEYS = [
|
||||
"chi_dinh",
|
||||
"lieu_luong_va_cach_dung",
|
||||
"chong_chi_dinh",
|
||||
"than_trong",
|
||||
];
|
||||
|
||||
// The client must never be the thing that gives up first.
|
||||
//
|
||||
// The backend's own per-request budget is 40s (`max_wall_clock_ms` in
|
||||
@@ -84,12 +118,16 @@ export function ChatPanel({
|
||||
onCitationClick,
|
||||
onCitationsLoaded,
|
||||
activeCitationIndex = null,
|
||||
monographPicker,
|
||||
onMonographChange,
|
||||
onToggleSection,
|
||||
className,
|
||||
}: ChatPanelProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseMode, setResponseMode] = useState<"ai" | "monograph">("ai");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const initialQuerySentRef = useRef<number | undefined>(undefined);
|
||||
@@ -136,6 +174,7 @@ export function ChatPanel({
|
||||
body: JSON.stringify({
|
||||
content: userText,
|
||||
conversationId: sessionId,
|
||||
responseMode,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
@@ -145,7 +184,56 @@ export function ChatPanel({
|
||||
}
|
||||
|
||||
const data: SendMessageResponse = await res.json();
|
||||
const assistantMsg = data.message;
|
||||
let assistantMsg = data.message;
|
||||
|
||||
if (
|
||||
assistantMsg.reason === "select_drug_sections" &&
|
||||
assistantMsg.resolvedDrugId &&
|
||||
!assistantMsg.resolvedDrugId.includes(",")
|
||||
) {
|
||||
const drugId = assistantMsg.resolvedDrugId;
|
||||
const [sectionsResponse, suggestionResponse] = await Promise.all([
|
||||
fetch(`/api/sections?drug_id=${encodeURIComponent(drugId)}`, {
|
||||
cache: "no-store",
|
||||
signal: abortControllerRef.current.signal,
|
||||
}),
|
||||
fetch(`/api/suggest?q=${encodeURIComponent(userText)}`, {
|
||||
cache: "no-store",
|
||||
signal: abortControllerRef.current.signal,
|
||||
}),
|
||||
]);
|
||||
if (!sectionsResponse.ok) {
|
||||
throw new Error("section_list_unavailable");
|
||||
}
|
||||
const rawSections = (await sectionsResponse.json()) as {
|
||||
sections?: Array<{ section_key: string; section_title: string }>;
|
||||
};
|
||||
const suggestionData = suggestionResponse.ok
|
||||
? ((await suggestionResponse.json()) as { suggestions?: string[] })
|
||||
: {};
|
||||
const sections: DrugSectionOption[] = (rawSections.sections ?? []).map(
|
||||
(section) => ({
|
||||
sectionKey: section.section_key,
|
||||
sectionTitle: section.section_title,
|
||||
})
|
||||
);
|
||||
const drugName = suggestionData.suggestions?.[0] ?? userText.trim();
|
||||
const picker: MonographPickerState = {
|
||||
drugId,
|
||||
drugName,
|
||||
sections,
|
||||
selectedSectionKeys: [],
|
||||
};
|
||||
assistantMsg = {
|
||||
...assistantMsg,
|
||||
content:
|
||||
`Chuyên luận ${drugName} có ${sections.length} mục. ` +
|
||||
"Chọn các mục cần xem ở cột bên phải rồi nhấn Gửi tra cứu — " +
|
||||
"nếu không chọn mục nào, hệ thống sẽ hiển thị toàn bộ.",
|
||||
sectionOptions: sections,
|
||||
};
|
||||
onMonographChange?.(picker);
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
@@ -175,6 +263,129 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMonograph = async () => {
|
||||
if (!monographPicker || isLoading) return;
|
||||
const selected = monographPicker.selectedSectionKeys.length
|
||||
? monographPicker.sections.filter((section) =>
|
||||
monographPicker.selectedSectionKeys.includes(section.sectionKey)
|
||||
)
|
||||
: monographPicker.sections;
|
||||
if (selected.length === 0) {
|
||||
setError("Chuyên luận này chưa có mục văn bản để hiển thị.");
|
||||
return;
|
||||
}
|
||||
|
||||
const label = monographPicker.selectedSectionKeys.length
|
||||
? selected.map((section) => section.sectionTitle).join(", ")
|
||||
: "Toàn bộ chuyên luận";
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-monograph-${Date.now()}`,
|
||||
role: "user",
|
||||
content: `${monographPicker.drugName} — ${label}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
stopRequestedRef.current = false;
|
||||
abortControllerRef.current = new AbortController();
|
||||
const timeoutId = window.setTimeout(
|
||||
() => abortControllerRef.current?.abort(),
|
||||
45_000
|
||||
);
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
selected.map(async (section) => {
|
||||
const response = await fetch(
|
||||
`/api/section-text?drug_id=${encodeURIComponent(
|
||||
monographPicker.drugId
|
||||
)}§ion_key=${encodeURIComponent(section.sectionKey)}`,
|
||||
{ cache: "no-store", signal: abortControllerRef.current!.signal }
|
||||
);
|
||||
if (!response.ok) throw new Error("section_text_unavailable");
|
||||
return (await response.json()) as SectionTextResponse;
|
||||
})
|
||||
);
|
||||
|
||||
const citations: Citation[] = [];
|
||||
const blocks: AnswerBlock[] = [];
|
||||
for (const response of responses) {
|
||||
const claims: AnswerBlock["claims"] = [];
|
||||
for (const [index, part] of response.parts.entries()) {
|
||||
if (
|
||||
part.printed_page_start == null ||
|
||||
part.printed_page_end == null ||
|
||||
part.physical_page == null
|
||||
) {
|
||||
throw new Error("section_provenance_missing");
|
||||
}
|
||||
const chunkId = `${response.drug_id}__${response.section_key}__${
|
||||
part.part_index ?? index
|
||||
}`;
|
||||
citations.push({
|
||||
chunkId,
|
||||
drugName: monographPicker.drugName.toUpperCase(),
|
||||
sectionType: response.section_key,
|
||||
sourceDocument: "Dược thư Quốc gia Việt Nam 2018",
|
||||
sourcePageRange: [part.printed_page_start, part.printed_page_end],
|
||||
physicalPage: part.physical_page,
|
||||
snippet: part.text,
|
||||
isQuarantined: part.is_quarantined,
|
||||
quarantineNotice: part.is_quarantined
|
||||
? "Mục này có bảng hoặc công thức cần đối chiếu trực tiếp trang PDF gốc."
|
||||
: undefined,
|
||||
});
|
||||
claims.push({ text: part.text, sourceIds: [chunkId] });
|
||||
}
|
||||
blocks.push({
|
||||
title:
|
||||
response.section_title ??
|
||||
selected.find((item) => item.sectionKey === response.section_key)
|
||||
?.sectionTitle ??
|
||||
response.section_key,
|
||||
kind: "fact_list",
|
||||
claims,
|
||||
});
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: `monograph-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Nguyên văn ${selected.length} mục của ${monographPicker.drugName}.`,
|
||||
citations,
|
||||
disclaimer: MONOGRAPH_DISCLAIMER,
|
||||
decision: "answerable",
|
||||
reason: "verbatim_sections",
|
||||
grounded: true,
|
||||
generated: false,
|
||||
resolvedDrugId: monographPicker.drugId,
|
||||
blocks,
|
||||
answerMode: "detailed",
|
||||
answerPlan: {
|
||||
verbosity: "detailed",
|
||||
layout: "bullet_list",
|
||||
reasoningMode: "direct_lookup",
|
||||
showHeading: true,
|
||||
needsWarning: false,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
onCitationsLoaded?.(citations);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.name === "AbortError"
|
||||
? "Đã dừng tải chuyên luận."
|
||||
: "Không thể tải đầy đủ nguyên văn các mục đã chọn. Vui lòng thử lại."
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
stopRequestedRef.current = true;
|
||||
@@ -182,6 +393,13 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseModeChange = (mode: "ai" | "monograph") => {
|
||||
setResponseMode(mode);
|
||||
if (mode === "ai") {
|
||||
onMonographChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortControllerRef.current?.abort();
|
||||
}, []);
|
||||
@@ -387,8 +605,38 @@ export function ChatPanel({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{msg.role === "assistant" &&
|
||||
msg.sectionOptions &&
|
||||
msg.sectionOptions.length > 0 && (
|
||||
<div className="ml-10 flex flex-wrap gap-2 px-4 pb-2">
|
||||
{QUICK_SECTION_KEYS.flatMap((key) => {
|
||||
const section = msg.sectionOptions?.find(
|
||||
(item) => item.sectionKey === key
|
||||
);
|
||||
if (!section) return [];
|
||||
const selected =
|
||||
monographPicker?.selectedSectionKeys.includes(key) ?? false;
|
||||
return [
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onToggleSection?.(key)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
selected
|
||||
? "border-accent-primary bg-accent-primary text-txt-inverse"
|
||||
: "border-border-accent bg-accent-soft text-accent-primary hover:bg-accent-primary hover:text-txt-inverse"
|
||||
)}
|
||||
>
|
||||
{section.sectionTitle}
|
||||
</button>,
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
msg.traceId &&
|
||||
msg.reason !== "select_drug_sections" &&
|
||||
!msg.traceId.startsWith("fallback-") && (
|
||||
<AnswerFeedback traceId={msg.traceId} conversationId={sessionId} />
|
||||
)}
|
||||
@@ -438,7 +686,17 @@ export function ChatPanel({
|
||||
|
||||
{/* 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} />
|
||||
<Composer
|
||||
onSubmit={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={handleStop}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={onToggleSection}
|
||||
onClearMonograph={() => onMonographChange?.(null)}
|
||||
onSubmitMonograph={handleSubmitMonograph}
|
||||
responseMode={responseMode}
|
||||
onResponseModeChange={handleResponseModeChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Send, Square, Sparkles, Pill, Search, Command } from "lucide-react";
|
||||
import type { MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { Send, Square, Sparkles, Pill, Search, Command, X, BookOpen } from "lucide-react";
|
||||
import { cn } from "@duoc-thu/ui";
|
||||
|
||||
interface ComposerProps {
|
||||
@@ -9,6 +10,12 @@ interface ComposerProps {
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
initialValue?: string;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
onClearMonograph?: () => void;
|
||||
onSubmitMonograph?: () => void;
|
||||
responseMode?: "ai" | "monograph";
|
||||
onResponseModeChange?: (mode: "ai" | "monograph") => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +24,12 @@ export function Composer({
|
||||
isLoading = false,
|
||||
onStop,
|
||||
initialValue = "",
|
||||
monographPicker,
|
||||
onToggleSection,
|
||||
onClearMonograph,
|
||||
onSubmitMonograph,
|
||||
responseMode = "ai",
|
||||
onResponseModeChange,
|
||||
className,
|
||||
}: ComposerProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
@@ -103,7 +116,12 @@ export function Composer({
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (isLoading) return;
|
||||
if (!trimmed && monographPicker) {
|
||||
onSubmitMonograph?.();
|
||||
return;
|
||||
}
|
||||
if (!trimmed) return;
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
setSuggestions([]);
|
||||
@@ -181,6 +199,36 @@ export function Composer({
|
||||
|
||||
{/* 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">
|
||||
{monographPicker && (
|
||||
<div className="flex flex-wrap items-center gap-2 px-2 pt-1 pb-2 border-b border-border-subtle/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearMonograph}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-accent-primary px-3 py-1.5 text-xs font-bold text-txt-inverse"
|
||||
>
|
||||
<Pill className="h-3.5 w-3.5" />
|
||||
{monographPicker.drugName}
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{monographPicker.selectedSectionKeys.map((sectionKey) => {
|
||||
const section = monographPicker.sections.find(
|
||||
(item) => item.sectionKey === sectionKey
|
||||
);
|
||||
if (!section) return null;
|
||||
return (
|
||||
<button
|
||||
key={sectionKey}
|
||||
type="button"
|
||||
onClick={() => onToggleSection?.(sectionKey)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border-accent bg-accent-soft px-3 py-1.5 text-xs font-semibold text-accent-primary"
|
||||
>
|
||||
{section.sectionTitle}
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
@@ -192,9 +240,33 @@ export function Composer({
|
||||
/>
|
||||
|
||||
<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 className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onResponseModeChange?.(
|
||||
responseMode === "ai" ? "monograph" : "ai"
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[0.7rem] font-bold transition-colors",
|
||||
responseMode === "monograph"
|
||||
? "border-border-accent bg-accent-soft text-accent-primary"
|
||||
: "border-border-subtle bg-surface-elevated text-txt-secondary hover:text-accent-primary"
|
||||
)}
|
||||
title="Chuyển giữa AI tổng hợp và tra nguyên văn chuyên luận"
|
||||
>
|
||||
{responseMode === "ai" ? (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{responseMode === "ai" ? "AI tổng hợp" : "Chuyên luận"}
|
||||
</button>
|
||||
<div className="hidden items-center gap-1.5 text-[0.7rem] text-txt-muted sm:flex">
|
||||
<Command className="w-3 h-3" />
|
||||
<span>Enter để gửi • Shift+Enter để xuống dòng</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -210,11 +282,11 @@ export function Composer({
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim()}
|
||||
disabled={!value.trim() && !monographPicker}
|
||||
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()
|
||||
value.trim() || monographPicker
|
||||
? "bg-accent-primary text-txt-inverse hover:bg-accent-hover"
|
||||
: "bg-surface-elevated text-txt-muted cursor-not-allowed"
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Citation } from "@duoc-thu/shared-types";
|
||||
import type { Citation, MonographPickerState } 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";
|
||||
@@ -10,6 +10,8 @@ interface EvidencePanelProps {
|
||||
citations: Citation[];
|
||||
activeCitationIndex: number | null;
|
||||
onSelectCitation: (citation: Citation, index: number) => void;
|
||||
monographPicker?: MonographPickerState | null;
|
||||
onToggleSection?: (sectionKey: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -18,6 +20,8 @@ export function EvidencePanel({
|
||||
citations,
|
||||
activeCitationIndex,
|
||||
onSelectCitation,
|
||||
monographPicker,
|
||||
onToggleSection,
|
||||
onClose,
|
||||
className,
|
||||
}: EvidencePanelProps) {
|
||||
@@ -36,13 +40,15 @@ export function EvidencePanel({
|
||||
</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>{monographPicker ? "Thuộc tính thuốc" : "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}
|
||||
{monographPicker ? monographPicker.sections.length : 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
|
||||
{monographPicker
|
||||
? "Chọn mục cần xem trong chuyên luận"
|
||||
: "Căn cứ chính thức Dược thư QGVN 2018"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +66,47 @@ export function EvidencePanel({
|
||||
|
||||
{/* Citations List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{citations.length === 0 ? (
|
||||
{monographPicker ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border-accent bg-accent-soft/40 p-4">
|
||||
<p className="m-0 text-sm font-extrabold text-accent-primary">
|
||||
{monographPicker.drugName}
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-[0.7rem] text-txt-muted">
|
||||
Chuyên luận · Dược thư Quốc gia Việt Nam 2018
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{monographPicker.sections.map((section) => {
|
||||
const checked = monographPicker.selectedSectionKeys.includes(
|
||||
section.sectionKey
|
||||
);
|
||||
return (
|
||||
<label
|
||||
key={section.sectionKey}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 text-xs transition-colors",
|
||||
checked
|
||||
? "border-border-accent bg-accent-soft text-accent-primary font-bold"
|
||||
: "border-transparent text-txt-secondary hover:border-border-subtle hover:bg-surface-elevated"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleSection?.(section.sectionKey)}
|
||||
className="h-4 w-4 rounded border-border-active accent-[var(--accent-primary)]"
|
||||
/>
|
||||
<span>{section.sectionTitle}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="m-0 rounded-xl bg-surface-elevated p-3 text-[0.7rem] leading-relaxed text-txt-muted">
|
||||
Không chọn mục nào rồi nhấn Gửi tra cứu để hiển thị toàn bộ chuyên luận.
|
||||
</p>
|
||||
</>
|
||||
) : 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" />
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SidebarProps {
|
||||
onNewChat: () => void;
|
||||
onDeleteSession?: (id: string) => void;
|
||||
onQuickQuery: (query: string) => void;
|
||||
historyRefreshKey?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ export function Sidebar({
|
||||
onNewChat,
|
||||
onDeleteSession,
|
||||
onQuickQuery,
|
||||
historyRefreshKey = 0,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -102,7 +104,7 @@ export function Sidebar({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentSessionId]);
|
||||
}, [currentSessionId, historyRefreshKey]);
|
||||
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
@@ -227,11 +227,13 @@ function toCitations(raw: RagCitation[]): Citation[] {
|
||||
export async function POST(request: Request) {
|
||||
let content: string;
|
||||
let conversationId: string | null = null;
|
||||
let responseMode: "ai" | "monograph" = "ai";
|
||||
try {
|
||||
const body = await request.json();
|
||||
content = typeof body?.content === "string" ? body.content.trim() : "";
|
||||
conversationId =
|
||||
typeof body?.conversationId === "string" ? body.conversationId : null;
|
||||
responseMode = body?.responseMode === "monograph" ? "monograph" : "ai";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
@@ -277,6 +279,7 @@ export async function POST(request: Request) {
|
||||
subject_scope: "human",
|
||||
intent: "fact_lookup",
|
||||
conversation_id: conversationId,
|
||||
response_mode: responseMode,
|
||||
}),
|
||||
cache: "no-store",
|
||||
// Propagate a browser disconnect/Stop action to the upstream fetch.
|
||||
|
||||
@@ -14,9 +14,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUrl = API_GATEWAY_URL.includes("/v1/rag")
|
||||
? `${API_GATEWAY_URL.replace(/\/query$/, "/history")}?conversation_id=${encodeURIComponent(conversationId)}`
|
||||
: `${API_GATEWAY_URL}/v1/rag/history?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
const base = API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
const targetUrl = `${base}/v1/rag/history?conversation_id=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "GET",
|
||||
@@ -24,6 +23,7 @@ export async function GET(request: Request) {
|
||||
"X-Client-Version": "1.0.0",
|
||||
},
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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:8000";
|
||||
|
||||
function ragBaseUrl() {
|
||||
return API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const drugId = params.get("drug_id")?.trim() ?? "";
|
||||
const sectionKey = params.get("section_key")?.trim() ?? "";
|
||||
if (
|
||||
!/^[a-z0-9_]{1,160}$/i.test(drugId) ||
|
||||
!/^[a-z0-9_]{1,80}$/i.test(sectionKey)
|
||||
) {
|
||||
return NextResponse.json({ error: "invalid_section_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`${ragBaseUrl()}/v1/rag/section-text?drug_id=${encodeURIComponent(
|
||||
drugId
|
||||
)}§ion_key=${encodeURIComponent(sectionKey)}`,
|
||||
{
|
||||
headers: { "X-Client-Version": "1.0.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
}
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: "section_text_unavailable" }, { status: upstream.status });
|
||||
}
|
||||
return NextResponse.json(await upstream.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "section_text_unavailable" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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:8000";
|
||||
|
||||
function ragBaseUrl() {
|
||||
return API_GATEWAY_URL.replace(/\/v1\/rag(?:\/query)?\/?$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const drugId = new URL(request.url).searchParams.get("drug_id")?.trim() ?? "";
|
||||
if (!/^[a-z0-9_]{1,160}$/i.test(drugId)) {
|
||||
return NextResponse.json({ error: "invalid_drug_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`${ragBaseUrl()}/v1/rag/sections?drug_id=${encodeURIComponent(drugId)}`,
|
||||
{
|
||||
headers: { "X-Client-Version": "1.0.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
}
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: "sections_unavailable" }, { status: upstream.status });
|
||||
}
|
||||
return NextResponse.json(await upstream.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "sections_unavailable" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
|
||||
import type { ChatMessage, Citation, MonographPickerState } from "@duoc-thu/shared-types";
|
||||
import { ChatPanel } from "./_components/ChatPanel";
|
||||
import { Sidebar, ChatSession } from "./_components/Sidebar";
|
||||
import { EvidencePanel } from "./_components/EvidencePanel";
|
||||
@@ -39,6 +39,7 @@ export default function ChatPage() {
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||
const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
|
||||
const [queryOverride, setQueryOverride] = useState<{ text: string; token: number } | null>(null);
|
||||
const [monographPicker, setMonographPicker] = useState<MonographPickerState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredSessions();
|
||||
@@ -106,6 +107,7 @@ export default function ChatPage() {
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
setMonographPicker(null);
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
@@ -114,6 +116,7 @@ export default function ChatPage() {
|
||||
setQueryOverride(null);
|
||||
setCitations([]);
|
||||
setActiveCitationIndex(null);
|
||||
setMonographPicker(null);
|
||||
setShowMobileSidebar(false);
|
||||
};
|
||||
|
||||
@@ -162,6 +165,7 @@ export default function ChatPage() {
|
||||
// must replace the panel's state, not just the index into a stale one.
|
||||
setCitations(allCitations);
|
||||
setActiveCitationIndex(index);
|
||||
setMonographPicker(null);
|
||||
setShowMobileEvidence(true);
|
||||
};
|
||||
|
||||
@@ -179,6 +183,16 @@ export default function ChatPage() {
|
||||
// coordinates are computed fresh.
|
||||
};
|
||||
|
||||
const handleToggleSection = (sectionKey: string) => {
|
||||
setMonographPicker((current) => {
|
||||
if (!current) return current;
|
||||
const selected = current.selectedSectionKeys.includes(sectionKey)
|
||||
? current.selectedSectionKeys.filter((key) => key !== sectionKey)
|
||||
: [...current.selectedSectionKeys, sectionKey];
|
||||
return { ...current, selectedSectionKeys: selected };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 w-full h-[calc(100vh-6.5rem)] overflow-hidden bg-app relative">
|
||||
{/* Mobile Header Bar Controls */}
|
||||
@@ -210,6 +224,7 @@ export default function ChatPage() {
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onQuickQuery={handleQuickQuery}
|
||||
historyRefreshKey={currentMessages.length}
|
||||
className="hidden lg:flex"
|
||||
/>
|
||||
|
||||
@@ -228,6 +243,7 @@ export default function ChatPage() {
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onQuickQuery={handleQuickQuery}
|
||||
historyRefreshKey={currentMessages.length}
|
||||
className="w-full h-full border-r-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -246,6 +262,9 @@ export default function ChatPage() {
|
||||
onCitationClick={handleCitationClick}
|
||||
onCitationsLoaded={handleCitationsLoaded}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
monographPicker={monographPicker}
|
||||
onMonographChange={setMonographPicker}
|
||||
onToggleSection={handleToggleSection}
|
||||
className="w-full max-w-4xl h-full"
|
||||
/>
|
||||
</main>
|
||||
@@ -256,6 +275,8 @@ export default function ChatPage() {
|
||||
citations={citations}
|
||||
activeCitationIndex={activeCitationIndex}
|
||||
onSelectCitation={(citation, index) => setActiveCitationIndex(index)}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={handleToggleSection}
|
||||
onClose={() => setShowEvidenceDesktop(false)}
|
||||
className="hidden lg:flex"
|
||||
/>
|
||||
@@ -275,6 +296,8 @@ export default function ChatPage() {
|
||||
onSelectCitation={(citation, index) => {
|
||||
setActiveCitationIndex(index);
|
||||
}}
|
||||
monographPicker={monographPicker}
|
||||
onToggleSection={handleToggleSection}
|
||||
onClose={() => setShowMobileEvidence(false)}
|
||||
className="w-full h-full border-l-0"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user