chatbot-template/components/toolbar.tsx

477 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
2024-10-30 16:01:24 +05:30
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from "framer-motion";
import { nanoid } from "nanoid";
2024-11-15 12:18:17 -05:00
import {
type Dispatch,
memo,
type ReactNode,
2024-11-15 12:18:17 -05:00
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { useOnClickOutside } from "usehooks-ts";
2024-10-30 16:01:24 +05:30
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ChatMessage } from "@/lib/types";
import { type ArtifactKind, artifactDefinitions } from "./artifact";
import type { ArtifactToolbarItem } from "./create-artifact";
import { ArrowUpIcon, StopIcon, SummarizeIcon } from "./icons";
2024-10-30 16:01:24 +05:30
type ToolProps = {
description: string;
2025-01-27 14:19:47 +05:30
icon: ReactNode;
2024-10-30 16:01:24 +05:30
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isToolbarVisible?: boolean;
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
2025-01-27 14:19:47 +05:30
onClick: ({
sendMessage,
2025-01-27 14:19:47 +05:30
}: {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
2025-01-27 14:19:47 +05:30
}) => void;
2024-10-30 16:01:24 +05:30
};
const Tool = ({
description,
icon,
selectedTool,
setSelectedTool,
isToolbarVisible,
setIsToolbarVisible,
isAnimating,
sendMessage,
2025-01-27 14:19:47 +05:30
onClick,
2024-10-30 16:01:24 +05:30
}: ToolProps) => {
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
2025-01-27 14:19:47 +05:30
if (selectedTool !== description) {
2024-10-30 16:01:24 +05:30
setIsHovered(false);
}
2025-01-27 14:19:47 +05:30
}, [selectedTool, description]);
2024-10-30 16:01:24 +05:30
2024-11-06 19:12:46 +03:00
const handleSelect = () => {
if (!isToolbarVisible && setIsToolbarVisible) {
setIsToolbarVisible(true);
return;
}
if (!selectedTool) {
setIsHovered(true);
2025-01-27 14:19:47 +05:30
setSelectedTool(description);
2024-11-06 19:12:46 +03:00
return;
}
2025-01-27 14:19:47 +05:30
if (selectedTool !== description) {
setSelectedTool(description);
2024-11-06 19:12:46 +03:00
} else {
2025-01-27 14:19:47 +05:30
setSelectedTool(null);
onClick({ sendMessage });
2024-11-06 19:12:46 +03:00
}
};
2024-10-30 16:01:24 +05:30
return (
<Tooltip open={isHovered && !isAnimating}>
2024-11-06 19:12:46 +03:00
<TooltipTrigger asChild>
2024-10-30 16:01:24 +05:30
<motion.div
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className={cx("rounded-full p-3", {
"bg-primary text-primary-foreground!": selectedTool === description,
2024-10-30 16:01:24 +05:30
})}
exit={{
scale: 0.9,
opacity: 0,
transition: { duration: 0.1 },
}}
initial={{ scale: 1, opacity: 0 }}
onClick={() => {
handleSelect();
2024-10-30 16:01:24 +05:30
}}
onHoverEnd={() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}}
onHoverStart={() => {
setIsHovered(true);
2024-10-30 16:01:24 +05:30
}}
2024-11-06 19:12:46 +03:00
onKeyDown={(event) => {
if (event.key === "Enter") {
2024-11-06 19:12:46 +03:00
handleSelect();
}
}}
2024-10-30 16:01:24 +05:30
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
2025-01-27 14:19:47 +05:30
{selectedTool === description ? <ArrowUpIcon /> : icon}
2024-10-30 16:01:24 +05:30
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background"
2024-10-30 16:01:24 +05:30
side="left"
sideOffset={16}
>
{description}
</TooltipContent>
</Tooltip>
);
};
const randomArr = [...new Array(6)].map((_x) => nanoid(5));
2024-11-15 12:18:17 -05:00
2024-10-30 16:01:24 +05:30
const ReadingLevelSelector = ({
setSelectedTool,
sendMessage,
2024-10-30 16:01:24 +05:30
isAnimating,
}: {
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
2024-10-30 16:01:24 +05:30
}) => {
const LEVELS = [
"Elementary",
"Middle School",
"Keep current level",
"High School",
"College",
"Graduate",
2024-10-30 16:01:24 +05:30
];
const y = useMotionValue(-40 * 2);
const dragConstraints = 5 * 40 + 2;
const yToLevel = useTransform(y, [0, -dragConstraints], [0, 5]);
const [currentLevel, setCurrentLevel] = useState(2);
const [hasUserSelectedLevel, setHasUserSelectedLevel] =
useState<boolean>(false);
useEffect(() => {
const unsubscribe = yToLevel.on("change", (latest) => {
2024-10-30 16:01:24 +05:30
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
setCurrentLevel(level);
});
return () => unsubscribe();
}, [yToLevel]);
return (
2025-09-09 15:44:07 -04:00
<div className="relative flex flex-col items-center justify-end">
2024-11-15 12:18:17 -05:00
{randomArr.map((id) => (
2024-10-30 16:01:24 +05:30
<motion.div
animate={{ opacity: 1 }}
className="flex size-[40px] flex-row items-center justify-center"
2024-10-30 16:01:24 +05:30
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={id}
2024-10-30 16:01:24 +05:30
transition={{ delay: 0.1 }}
>
<div className="size-2 rounded-full bg-muted-foreground/40" />
</motion.div>
))}
<TooltipProvider>
<Tooltip open={!isAnimating}>
<TooltipTrigger asChild>
<motion.div
className={cx(
"absolute flex flex-row items-center rounded-full border bg-background p-3",
2024-10-30 16:01:24 +05:30
{
"bg-primary text-primary-foreground": currentLevel !== 2,
"bg-background text-foreground": currentLevel === 2,
}
2024-10-30 16:01:24 +05:30
)}
drag="y"
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
2024-10-30 16:01:24 +05:30
dragElastic={0}
dragMomentum={false}
onClick={() => {
if (currentLevel !== 2 && hasUserSelectedLevel) {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
},
],
2024-10-30 16:01:24 +05:30
});
setSelectedTool(null);
}
}}
onDragEnd={() => {
if (currentLevel === 2) {
setSelectedTool(null);
} else {
setHasUserSelectedLevel(true);
}
}}
onDragStart={() => {
setHasUserSelectedLevel(false);
}}
style={{ y }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
2024-10-30 16:01:24 +05:30
>
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
2024-10-30 16:01:24 +05:30
side="left"
sideOffset={16}
>
{LEVELS[currentLevel]}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
2024-11-06 19:12:46 +03:00
export const Tools = ({
2024-10-30 16:01:24 +05:30
isToolbarVisible,
2024-11-06 19:12:46 +03:00
selectedTool,
setSelectedTool,
sendMessage,
2024-11-06 19:12:46 +03:00
isAnimating,
setIsToolbarVisible,
2025-01-27 14:19:47 +05:30
tools,
2024-10-30 16:01:24 +05:30
}: {
isToolbarVisible: boolean;
2024-11-06 19:12:46 +03:00
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
2024-11-06 19:12:46 +03:00
isAnimating: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
tools: ArtifactToolbarItem[];
2024-10-30 16:01:24 +05:30
}) => {
2025-01-27 14:19:47 +05:30
const [primaryTool, ...secondaryTools] = tools;
2024-11-06 19:12:46 +03:00
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-1.5"
2024-11-06 19:12:46 +03:00
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
2024-11-06 19:12:46 +03:00
>
2024-10-30 16:01:24 +05:30
<AnimatePresence>
{isToolbarVisible &&
secondaryTools.map((secondaryTool) => (
2024-10-30 16:01:24 +05:30
<Tool
description={secondaryTool.description}
icon={secondaryTool.icon}
2024-10-30 16:01:24 +05:30
isAnimating={isAnimating}
key={secondaryTool.description}
2025-01-27 14:19:47 +05:30
onClick={secondaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
2024-10-30 16:01:24 +05:30
/>
))}
2024-10-30 16:01:24 +05:30
</AnimatePresence>
<Tool
description={primaryTool.description}
icon={primaryTool.icon}
2024-10-30 16:01:24 +05:30
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
2025-01-27 14:19:47 +05:30
onClick={primaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
2024-10-30 16:01:24 +05:30
/>
2024-11-06 19:12:46 +03:00
</motion.div>
2024-10-30 16:01:24 +05:30
);
2024-11-06 19:12:46 +03:00
};
const PureToolbar = ({
2024-11-06 19:12:46 +03:00
isToolbarVisible,
setIsToolbarVisible,
sendMessage,
status,
2024-11-06 19:12:46 +03:00
stop,
setMessages,
artifactKind,
2024-11-06 19:12:46 +03:00
}: {
isToolbarVisible: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
status: UseChatHelpers<ChatMessage>["status"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
stop: UseChatHelpers<ChatMessage>["stop"];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
artifactKind: ArtifactKind;
2024-11-06 19:12:46 +03:00
}) => {
const toolbarRef = useRef<HTMLDivElement>(null);
2024-11-15 12:18:17 -05:00
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
2024-11-06 19:12:46 +03:00
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
useOnClickOutside(toolbarRef, () => {
setIsToolbarVisible(false);
setSelectedTool(null);
});
2024-10-30 16:01:24 +05:30
const startCloseTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setSelectedTool(null);
setIsToolbarVisible(false);
}, 2000);
};
const cancelCloseTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
if (status === "streaming") {
2024-10-30 16:01:24 +05:30
setIsToolbarVisible(false);
}
}, [status, setIsToolbarVisible]);
2024-10-30 16:01:24 +05:30
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifactKind
2025-01-27 14:19:47 +05:30
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
2025-01-27 14:19:47 +05:30
}
const toolsByArtifactKind = artifactDefinition.toolbar;
2025-01-27 14:19:47 +05:30
if (toolsByArtifactKind.length === 0) {
2025-01-15 19:59:29 +05:30
return null;
}
2024-10-30 16:01:24 +05:30
return (
<TooltipProvider delayDuration={0}>
<motion.div
animate={
isToolbarVisible
? selectedTool === "adjust-reading-level"
2024-10-30 16:01:24 +05:30
? {
opacity: 1,
y: 0,
height: 6 * 43,
transition: { delay: 0 },
scale: 0.95,
}
: {
opacity: 1,
y: 0,
height: toolsByArtifactKind.length * 50,
2024-10-30 16:01:24 +05:30
transition: { delay: 0 },
scale: 1,
}
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
}
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
2024-10-30 16:01:24 +05:30
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
setIsAnimating(false);
}}
onAnimationStart={() => {
setIsAnimating(true);
2024-10-30 16:01:24 +05:30
}}
onHoverEnd={() => {
if (status === "streaming") {
return;
}
2024-10-30 16:01:24 +05:30
startCloseTimer();
}}
onHoverStart={() => {
if (status === "streaming") {
return;
}
cancelCloseTimer();
setIsToolbarVisible(true);
2024-10-30 16:01:24 +05:30
}}
ref={toolbarRef}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
2024-10-30 16:01:24 +05:30
>
{status === "streaming" ? (
2024-11-06 19:12:46 +03:00
<motion.div
animate={{ scale: 1.4 }}
2024-10-30 16:01:24 +05:30
className="p-3"
exit={{ scale: 1 }}
initial={{ scale: 1 }}
key="stop-icon"
2024-10-30 16:01:24 +05:30
onClick={() => {
stop();
setMessages((messages) => messages);
2024-10-30 16:01:24 +05:30
}}
>
<StopIcon />
2024-11-06 19:12:46 +03:00
</motion.div>
) : selectedTool === "adjust-reading-level" ? (
2024-10-30 16:01:24 +05:30
<ReadingLevelSelector
isAnimating={isAnimating}
2024-11-06 19:12:46 +03:00
key="reading-level-selector"
sendMessage={sendMessage}
2024-10-30 16:01:24 +05:30
setSelectedTool={setSelectedTool}
/>
) : (
2024-11-06 19:12:46 +03:00
<Tools
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
2024-11-06 19:12:46 +03:00
selectedTool={selectedTool}
sendMessage={sendMessage}
2024-11-06 19:12:46 +03:00
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
2024-11-06 19:12:46 +03:00
/>
2024-10-30 16:01:24 +05:30
)}
</motion.div>
</TooltipProvider>
);
};
export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) {
return false;
}
if (prevProps.artifactKind !== nextProps.artifactKind) {
return false;
}
2024-12-10 17:54:10 +05:30
return true;
});