Files
duocthu/apps/web/app/_components/Composer.tsx
T

304 lines
11 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "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 {
onSubmit: (query: string) => void;
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;
}
export function Composer({
onSubmit,
isLoading = false,
onStop,
initialValue = "",
monographPicker,
onToggleSection,
onClearMonograph,
onSubmitMonograph,
responseMode = "ai",
onResponseModeChange,
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.
//
// `term` is the LAST WORD being typed, not the whole textarea — a natural
// sentence like "cho tôi thuốc parace" has no drug alias containing that
// full phrase, so matching against it always returns empty (found live
// 2026-08-10: this silently killed every suggestion once a user typed a
// sentence instead of a bare drug name, which is the more common case).
useEffect(() => {
const words = value.trim().split(/\s+/);
const term = words[words.length - 1] ?? "";
if (term.length < 2) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
// Race guard: a slower request for an earlier keystroke must not
// overwrite a faster one for a later keystroke — found live 2026-08-10
// typing fast could show a stale, unrelated suggestion list because the
// fetch callback set state unconditionally regardless of whether `term`
// (and therefore `value`) had already changed by the time it resolved.
let cancelled = false;
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(term)}`);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
if (cancelled) return;
if (data?.suggestions && Array.isArray(data.suggestions) && data.suggestions.length > 0) {
setSuggestions(data.suggestions);
setShowSuggestions(true);
return;
}
}
if (cancelled) return;
setSuggestions([]);
setShowSuggestions(false);
} catch {
if (!cancelled) {
setSuggestions([]);
setShowSuggestions(false);
}
}
}, 200);
return () => {
cancelled = true;
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 (isLoading) return;
if (!trimmed && monographPicker) {
onSubmitMonograph?.();
return;
}
if (!trimmed) return;
onSubmit(trimmed);
setValue("");
setSuggestions([]);
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") {
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();
applySuggestion(suggestions[selectedIndex]);
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={() => {
applySuggestion(item);
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">
{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}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nhập câu hỏi của bạn..."
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-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">
{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() && !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() || monographPicker
? "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>
);
}