232 lines
8.2 KiB
TypeScript
232 lines
8.2 KiB
TypeScript
"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;
|
|
}
|
|
|
|
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.
|
|
//
|
|
// `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 (!trimmed || isLoading) 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">
|
|
<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-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>
|
|
);
|
|
}
|