diff --git a/components.json b/components.json index 3b57dc5a2..80e9822c2 100644 --- a/components.json +++ b/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +18,8 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "phosphor" + "registries": { + "@agents-ui": "http://livekit.io/ui/r/{name}.json", + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + } } diff --git a/components/agents-ui/agent-audio-visualizer-bar.tsx b/components/agents-ui/agent-audio-visualizer-bar.tsx new file mode 100644 index 000000000..030b087fa --- /dev/null +++ b/components/agents-ui/agent-audio-visualizer-bar.tsx @@ -0,0 +1,150 @@ +'use client'; + +import React, { + type CSSProperties, + Children, + type ReactNode, + cloneElement, + isValidElement, + useMemo, +} from 'react'; +import { type VariantProps, cva } from 'class-variance-authority'; +import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'; +import { + type AgentState, + type TrackReferenceOrPlaceholder, + useMultibandTrackVolume, +} from '@livekit/components-react'; +import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar'; +import { cn } from '@/lib/utils'; + +function cloneSingleChild( + children: ReactNode | ReactNode[], + props?: Record, + key?: unknown +) { + return Children.map(children, (child) => { + // Checking isValidElement is the safe way and avoids a typescript error too. + if (isValidElement(child) && Children.only(children)) { + const childProps = child.props as Record; + if (childProps.className) { + // make sure we retain classnames of both passed props and child + props ??= {}; + props.className = cn(childProps.className as string, props.className as string); + props.style = { + ...(childProps.style as CSSProperties), + ...(props.style as CSSProperties), + }; + } + return cloneElement(child, { ...props, key: key ? String(key) : undefined }); + } + return child; + }); +} + +export const AgentAudioVisualizerBarVariants = cva( + [ + 'relative flex items-center justify-center', + '[&_>_*]:rounded-full [&_>_*]:transition-colors [&_>_*]:duration-250 [&_>_*]:ease-linear', + '[&_>_*]:bg-transparent [&_>_*]:data-[lk-highlighted=true]:bg-current', + ], + { + variants: { + size: { + icon: ['h-[24px] gap-[2px]', '[&_>_*]:w-[4px] [&_>_*]:min-h-[4px]'], + sm: ['h-[56px] gap-[4px]', '[&_>_*]:w-[8px] [&_>_*]:min-h-[8px]'], + md: ['h-[112px] gap-[8px]', '[&_>_*]:w-[16px] [&_>_*]:min-h-[16px]'], + lg: ['h-[224px] gap-[16px]', '[&_>_*]:w-[32px] [&_>_*]:min-h-[32px]'], + xl: ['h-[448px] gap-[32px]', '[&_>_*]:w-[64px] [&_>_*]:min-h-[64px]'], + }, + }, + defaultVariants: { + size: 'md', + }, + } +); + +export interface AgentAudioVisualizerBarProps { + state?: AgentState; + barCount?: number; + audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder; + className?: string; + children?: ReactNode | ReactNode[]; +} + +export function AgentAudioVisualizerBar({ + size = 'md', + state = 'connecting', + barCount, + audioTrack, + className, + children, +}: AgentAudioVisualizerBarProps & VariantProps) { + const _barCount = useMemo(() => { + if (barCount) { + return barCount; + } + switch (size) { + case 'icon': + case 'sm': + return 3; + default: + return 5; + } + }, [barCount, size]); + + const volumeBands = useMultibandTrackVolume(audioTrack, { + bands: _barCount, + loPass: 100, + hiPass: 200, + }); + + const sequencerInterval = useMemo(() => { + switch (state) { + case 'connecting': + return 2000 / _barCount; + case 'initializing': + return 2000; + case 'listening': + return 500; + case 'thinking': + return 150; + default: + return 1000; + } + }, [state, _barCount]); + + const highlightedIndices = useAgentAudioVisualizerBarAnimator( + state, + _barCount, + sequencerInterval + ); + + const bands = useMemo( + () => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)), + [state, volumeBands, _barCount] + ); + + return ( +
+ {bands.map((band: number, idx: number) => + children ? ( + + {cloneSingleChild(children, { + 'data-lk-index': idx, + 'data-lk-highlighted': highlightedIndices.includes(idx), + style: { height: `${band * 100}%` }, + })} + + ) : ( +
+ ) + )} +
+ ); +} diff --git a/components/agents-ui/agent-chat-indicator.tsx b/components/agents-ui/agent-chat-indicator.tsx new file mode 100644 index 000000000..c9ed0269c --- /dev/null +++ b/components/agents-ui/agent-chat-indicator.tsx @@ -0,0 +1,57 @@ +import { type VariantProps, cva } from 'class-variance-authority'; +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; + +const motionAnimationProps = { + variants: { + hidden: { + opacity: 0, + scale: 0.1, + transition: { + duration: 0.1, + ease: 'linear', + }, + }, + visible: { + opacity: [0.5, 1], + scale: [1, 1.2], + transition: { + type: 'spring', + bounce: 0, + duration: 0.5, + repeat: Infinity, + repeatType: 'mirror' as const, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +const agentChatIndicatorVariants = cva('bg-muted-foreground inline-block size-2.5 rounded-full', { + variants: { + size: { + sm: 'size-2.5', + md: 'size-4', + lg: 'size-6', + }, + }, + defaultVariants: { + size: 'md', + }, +}); + +export interface AgentChatIndicatorProps extends VariantProps { + className?: string; +} + +export function AgentChatIndicator({ size, className }: AgentChatIndicatorProps) { + return ( + + ); +} diff --git a/components/agents-ui/agent-chat-transcript.tsx b/components/agents-ui/agent-chat-transcript.tsx new file mode 100644 index 000000000..1daefff85 --- /dev/null +++ b/components/agents-ui/agent-chat-transcript.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { AnimatePresence } from 'motion/react'; +import { type AgentState, type ReceivedMessage } from '@livekit/components-react'; +import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator'; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; +import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'; + +export interface AgentChatTranscriptProps { + agentState?: AgentState; + messages?: ReceivedMessage[]; + className?: string; +} + +export function AgentChatTranscript({ + agentState, + messages = [], + className, +}: AgentChatTranscriptProps) { + return ( + + + {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 ( + + + {message} + + + ); + })} + + {agentState === 'thinking' && } + + + + + ); +} diff --git a/components/agents-ui/agent-control-bar.tsx b/components/agents-ui/agent-control-bar.tsx new file mode 100644 index 000000000..f3f11bb45 --- /dev/null +++ b/components/agents-ui/agent-control-bar.tsx @@ -0,0 +1,313 @@ +'use client'; + +import { type HTMLAttributes, useEffect, useRef, useState } from 'react'; +import { Track } from 'livekit-client'; +import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react'; +import { motion } from 'motion/react'; +import { useChat } from '@livekit/components-react'; +import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button'; +import { AgentTrackControl } from '@/components/agents-ui/agent-track-control'; +import { + AgentTrackToggle, + agentTrackToggleVariants, +} from '@/components/agents-ui/agent-track-toggle'; +import { Button } from '@/components/ui/button'; +import { Toggle } from '@/components/ui/toggle'; +import { + type UseInputControlsProps, + useInputControls, + usePublishPermissions, +} from '@/hooks/agents-ui/use-agent-control-bar'; +import { cn } from '@/lib/utils'; + +const TOGGLE_VARIANT_1 = [ + '[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10', + '[&_[data-state=off]_~_button]:bg-accent [&_[data-state=off]_~_button]:hover:bg-foreground/10', + '[&_[data-state=off]]:border-border [&_[data-state=off]]:hover:border-foreground/12', + '[&_[data-state=off]_~_button]:border-border [&_[data-state=off]_~_button]:hover:border-foreground/12', + '[&_[data-state=off]]:text-destructive [&_[data-state=off]]:hover:text-destructive [&_[data-state=off]]:focus:text-destructive', + '[&_[data-state=off]]:focus-visible:ring-foreground/12 [&_[data-state=off]]:focus-visible:border-ring', + 'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10', +]; + +const TOGGLE_VARIANT_2 = [ + 'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10', + 'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12', + 'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12', + 'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground', + 'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30', + 'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30', + 'data-[state=on]:focus-visible:border-blue-700/50', + 'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300', +]; + +const MOTION_PROPS = { + variants: { + hidden: { + height: 0, + opacity: 0, + marginBottom: 0, + }, + visible: { + height: 'auto', + opacity: 1, + marginBottom: 12, + }, + }, + initial: 'hidden', + transition: { + duration: 0.3, + ease: 'easeOut', + }, +}; + +interface AgentChatInputProps { + chatOpen: boolean; + onSend?: (message: string) => void; + className?: string; +} + +export function AgentChatInput({ + chatOpen, + onSend = async () => {}, + className, +}: 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 || message.trim().length === 0; + + useEffect(() => { + if (chatOpen) return; + // when not disabled refocus on input + inputRef.current?.focus(); + }, [chatOpen]); + + return ( +
+