Files
deer-flow/web/src/app/_components/input-box.tsx
2025-04-17 14:26:41 +08:00

171 lines
4.9 KiB
TypeScript

// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { ArrowUpOutlined, CloseOutlined } from "@ant-design/icons";
import { AnimatePresence, motion } from "framer-motion";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import type { Option } from "~/core/messages";
import { cn } from "~/lib/utils";
export function InputBox({
className,
size,
responding,
feedback,
onSend,
onCancel,
onRemoveFeedback,
}: {
className?: string;
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (message: string, feedback: { option: Option } | null) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
const [message, setMessage] = useState("");
const [imeStatus, setImeStatus] = useState<"active" | "inactive">("inactive");
const [indent, setIndent] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const feedbackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (feedback) {
setMessage("");
setTimeout(() => {
if (feedbackRef.current) {
setIndent(feedbackRef.current.offsetWidth);
}
}, 200);
}
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}, [feedback]);
const handleSendMessage = useCallback(() => {
if (responding) {
onCancel?.();
} else {
if (message.trim() === "") {
return;
}
if (onSend) {
onSend(message, feedback ?? null);
setMessage("");
onRemoveFeedback?.();
}
}
}, [responding, onCancel, message, onSend, feedback, onRemoveFeedback]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (responding) {
return;
}
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
imeStatus === "inactive"
) {
event.preventDefault();
handleSendMessage();
}
},
[responding, imeStatus, handleSendMessage],
);
return (
<div className={cn("relative rounded-[24px] border bg-white", className)}>
<div className="w-full">
<AnimatePresence>
{feedback && (
<motion.div
ref={feedbackRef}
className="absolute top-0 left-0 mt-3 ml-2 flex items-center justify-center gap-1 rounded-2xl border border-[#007aff] bg-white px-2 py-0.5"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex h-full w-full items-center justify-center text-sm text-[#007aff] opacity-90">
{feedback.option.text}
</div>
<CloseOutlined
className="cursor-pointer text-[9px]"
onClick={onRemoveFeedback}
/>
</motion.div>
)}
</AnimatePresence>
<textarea
ref={textareaRef}
className={cn(
"m-0 w-full resize-none border-none px-4 py-3 text-lg",
size === "large" ? "min-h-32" : "min-h-4",
)}
style={{ textIndent: feedback ? `${indent}px` : 0 }}
placeholder={
feedback
? `Describe how you ${feedback.option.text.toLocaleLowerCase()}?`
: "What can I do for you?"
}
value={message}
onCompositionStart={() => setImeStatus("active")}
onCompositionEnd={() => setImeStatus("inactive")}
onKeyDown={handleKeyDown}
onChange={(event) => {
setMessage(event.target.value);
}}
/>
</div>
<div className="flex items-center px-4 py-2">
<div className="flex grow"></div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"h-10 w-10 rounded-full",
responding ? "bg-button-hover" : "bg-button",
)}
onClick={handleSendMessage}
>
{responding ? (
<div className="flex h-10 w-10 items-center justify-center">
<div className="h-4 w-4 rounded-sm bg-red-300" />
</div>
) : (
<ArrowUpOutlined />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{responding ? "Stop" : "Send"}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}