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