diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..0df847b9d --- /dev/null +++ b/components/ai-elements/conversation.tsx @@ -0,0 +1,87 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import { useCallback } from 'react'; +import { ArrowDownIcon } from 'lucide-react'; +import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps; + +export const ConversationContent = ({ className, ...props }: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<'div'> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = 'No messages yet', + description = 'Start a conversation to see messages here', + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description &&

{description}

} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx new file mode 100644 index 000000000..b68ae519d --- /dev/null +++ b/components/ai-elements/message.tsx @@ -0,0 +1,367 @@ +'use client'; + +import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'; +import { createContext, memo, useContext, useEffect, useState } from 'react'; +import type { FileUIPart, UIMessage } from 'ai'; +import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from 'lucide-react'; +import { Streamdown } from 'streamdown'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +export type MessageProps = HTMLAttributes & { + from: UIMessage['role']; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ children, className, ...props }: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<'div'>; + +export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = 'ghost', + size = 'icon-sm', + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext(null); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error('MessageBranch components must be used within MessageBranch'); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0', className)} {...props} /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0', + index === currentBranch ? 'block' : 'hidden' + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage['role']; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ children, className, ...props }: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0', className)} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = 'MessageResponse'; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) { + const filename = data.filename || ''; + const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file'; + const isImage = mediaType === 'image'; + const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment'); + + return ( +
+ {isImage ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + + +
+ +
+
+ +

{attachmentLabel}

+
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<'div'>; + +export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<'div'>; + +export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx new file mode 100644 index 000000000..e434906b7 --- /dev/null +++ b/components/ai-elements/shimmer.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from 'react'; +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; + +export type TextShimmerProps = { + children: string; + as?: ElementType; + className?: string; + duration?: number; + spread?: number; +}; + +const ShimmerComponent = ({ + children, + as: Component = 'p', + className, + duration = 2, + spread = 2, +}: TextShimmerProps) => { + const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements); + + const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]); + + return ( + + {children} + + ); +}; + +export const Shimmer = memo(ShimmerComponent); diff --git a/components/app/app.tsx b/components/app/app.tsx index 6dbec1d26..f25f27496 100644 --- a/components/app/app.tsx +++ b/components/app/app.tsx @@ -8,9 +8,10 @@ import { StartAudio, useSession, } from '@livekit/components-react'; +import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; import type { AppConfig } from '@/app-config'; import { ViewController } from '@/components/app/view-controller'; -import { Toaster } from '@/components/livekit/toaster'; +import { Toaster } from '@/components/ui/sonner'; import { useAgentErrors } from '@/hooks/useAgentErrors'; import { useDebugMode } from '@/hooks/useDebug'; import { getSandboxTokenSource } from '@/lib/utils'; @@ -48,7 +49,20 @@ export function App({ appConfig }: AppProps) { - + , + }} + position="top-center" + className="toaster group" + style={ + { + '--normal-bg': 'var(--popover)', + '--normal-text': 'var(--popover-foreground)', + '--normal-border': 'var(--border)', + } as React.CSSProperties + } + /> ); } diff --git a/components/app/chat-transcript.tsx b/components/app/chat-transcript.tsx index 520f955c5..6a0c2227e 100644 --- a/components/app/chat-transcript.tsx +++ b/components/app/chat-transcript.tsx @@ -2,10 +2,16 @@ import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react'; import { type ReceivedMessage } from '@livekit/components-react'; -import { ChatEntry } from '@/components/livekit/chat-entry'; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; +import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'; +import { cn } from '@/lib/utils'; const MotionContainer = motion.create('div'); -const MotionChatEntry = motion.create(ChatEntry); +const MotionMessage = motion.create(Message); const CONTAINER_MOTION_PROPS = { variants: { @@ -14,8 +20,6 @@ const CONTAINER_MOTION_PROPS = { transition: { ease: 'easeOut', duration: 0.3, - staggerChildren: 0.1, - staggerDirection: -1, }, }, visible: { @@ -24,9 +28,6 @@ const CONTAINER_MOTION_PROPS = { delay: 0.2, ease: 'easeOut', duration: 0.3, - stagerDelay: 0.2, - staggerChildren: 0.1, - staggerDirection: 1, }, }, }, @@ -46,6 +47,8 @@ const MESSAGE_MOTION_PROPS = { translateY: 0, }, }, + initial: 'hidden', + whileInView: 'visible', }; interface ChatTranscriptProps { @@ -56,33 +59,46 @@ interface ChatTranscriptProps { export function ChatTranscript({ hidden = false, messages = [], + className, ...props }: ChatTranscriptProps & Omit, 'ref'>) { return ( - - {!hidden && ( - - {messages.map((receivedMessage) => { - const { id, timestamp, from, message } = receivedMessage; - const locale = navigator?.language ?? 'en-US'; - const messageOrigin = from?.isLocal ? 'local' : 'remote'; - const hasBeenEdited = - receivedMessage.type === 'chatMessage' && !!receivedMessage.editTimestamp; +
+ + {!hidden && ( + + + + {messages.map((receivedMessage) => { + const { id, timestamp, from, message } = receivedMessage; + const locale = navigator?.language ?? 'en-US'; + const messageOrigin = from?.isLocal ? 'user' : 'assistant'; + const time = new Date(timestamp); + const title = time.toLocaleTimeString(locale, { timeStyle: 'full' }); - return ( - - ); - })} - - )} - + return ( + + + {message} + + + ); + })} + + + + + )} + +
); } diff --git a/components/app/preconnect-message.tsx b/components/app/preconnect-message.tsx deleted file mode 100644 index f28044ea6..000000000 --- a/components/app/preconnect-message.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import { AnimatePresence, motion } from 'motion/react'; -import { type ReceivedMessage } from '@livekit/components-react'; -import { ShimmerText } from '@/components/livekit/shimmer-text'; -import { cn } from '@/lib/utils'; - -const MotionMessage = motion.create('p'); - -const VIEW_MOTION_PROPS = { - variants: { - visible: { - opacity: 1, - transition: { - ease: 'easeIn', - duration: 0.5, - delay: 0.8, - }, - }, - hidden: { - opacity: 0, - transition: { - ease: 'easeIn', - duration: 0.5, - delay: 0, - }, - }, - }, - initial: 'hidden', - animate: 'visible', - exit: 'hidden', -}; - -interface PreConnectMessageProps { - messages?: ReceivedMessage[]; - className?: string; -} - -export function PreConnectMessage({ className, messages = [] }: PreConnectMessageProps) { - return ( - - {messages.length === 0 && ( - 0} - className={cn('pointer-events-none text-center', className)} - > - - Agent is listening, ask it a question - - - )} - - ); -} diff --git a/components/app/session-view.tsx b/components/app/session-view.tsx index 5d7366405..150c1b64e 100644 --- a/components/app/session-view.tsx +++ b/components/app/session-view.tsx @@ -1,21 +1,22 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import { motion } from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; import { useSessionContext, useSessionMessages } from '@livekit/components-react'; import type { AppConfig } from '@/app-config'; import { ChatTranscript } from '@/components/app/chat-transcript'; -import { PreConnectMessage } from '@/components/app/preconnect-message'; import { TileLayout } from '@/components/app/tile-layout'; import { AgentControlBar, type ControlBarControls, } from '@/components/livekit/agent-control-bar/agent-control-bar'; import { cn } from '@/lib/utils'; -import { ScrollArea } from '../livekit/scroll-area/scroll-area'; +import { Shimmer } from '../ai-elements/shimmer'; const MotionBottom = motion.create('div'); +const MotionMessage = motion.create(Shimmer); + const BOTTOM_VIEW_MOTION_PROPS = { variants: { visible: { @@ -37,6 +38,30 @@ const BOTTOM_VIEW_MOTION_PROPS = { }, }; +const SHIMMER_MOTION_PROPS = { + variants: { + visible: { + opacity: 1, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0.8, + }, + }, + hidden: { + opacity: 0, + transition: { + ease: 'easeIn', + duration: 0.5, + delay: 0, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + interface FadeProps { top?: boolean; bottom?: boolean; @@ -87,34 +112,36 @@ export const SessionView = ({ }, [messages]); return ( -
- {/* Chat Transcript */} -
- - - -
- - {/* Tile Layout */} +
+ + {/* transcript */} +
diff --git a/components/livekit/agent-control-bar/agent-chat-input.tsx b/components/livekit/agent-control-bar/agent-chat-input.tsx new file mode 100644 index 000000000..b92df3463 --- /dev/null +++ b/components/livekit/agent-control-bar/agent-chat-input.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react'; +import { PaperPlaneRightIcon, SpinnerIcon } from '@phosphor-icons/react/dist/ssr'; +import { Button } from '@/components/ui/button'; + +interface AgentChatInputProps { + chatOpen: boolean; + isAgentAvailable?: boolean; + onSend?: (message: string) => void; +} + +export function AgentChatInput({ + chatOpen, + isAgentAvailable = false, + onSend = async () => {}, +}: AgentChatInputProps) { + const inputRef = useRef(null); + const [isSending, setIsSending] = useState(false); + const [message, setMessage] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + setIsSending(true); + await onSend(message); + setMessage(''); + } catch (error) { + console.error(error); + } finally { + setIsSending(false); + } + }; + + const isDisabled = isSending || !isAgentAvailable || message.trim().length === 0; + + useEffect(() => { + if (chatOpen && isAgentAvailable) return; + // when not disabled refocus on input + inputRef.current?.focus(); + }, [chatOpen, isAgentAvailable]); + + return ( +
+