fix(voice): keep LiveKit TTS tracks alive
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { TokenSource } from 'livekit-client';
|
||||
import { useSession } from '@livekit/components-react';
|
||||
import { WarningIcon } from '@phosphor-icons/react/dist/ssr';
|
||||
import type { AppConfig } from '@/app-config';
|
||||
import { AgentSessionProvider } from '@/components/agents-ui/agent-session-provider';
|
||||
import { StartAudioButton } from '@/components/agents-ui/start-audio-button';
|
||||
import { ViewController } from '@/components/app/view-controller';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { useAgentErrors } from '@/hooks/useAgentErrors';
|
||||
import { useDebugMode } from '@/hooks/useDebug';
|
||||
import { getSandboxTokenSource } from '@/lib/utils';
|
||||
|
||||
const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production';
|
||||
|
||||
function AppSetup() {
|
||||
useDebugMode({ enabled: IN_DEVELOPMENT });
|
||||
useAgentErrors();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AppProps {
|
||||
appConfig: AppConfig;
|
||||
}
|
||||
|
||||
export function App({ appConfig }: AppProps) {
|
||||
const tokenSource = useMemo(() => {
|
||||
return typeof process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT === 'string'
|
||||
? getSandboxTokenSource(appConfig)
|
||||
: TokenSource.endpoint('/api/connection-details');
|
||||
}, [appConfig]);
|
||||
|
||||
const session = useSession(
|
||||
tokenSource,
|
||||
appConfig.agentName ? { agentName: appConfig.agentName } : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentSessionProvider session={session}>
|
||||
<AppSetup />
|
||||
<main className="grid h-svh grid-cols-1 place-content-center">
|
||||
<ViewController appConfig={appConfig} />
|
||||
</main>
|
||||
<StartAudioButton label="Start Audio" />
|
||||
<Toaster
|
||||
icons={{
|
||||
warning: <WarningIcon weight="bold" />,
|
||||
}}
|
||||
position="top-center"
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</AgentSessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
|
||||
import { type ReceivedMessage, useAgent } from '@livekit/components-react';
|
||||
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const MotionContainer = motion.create('div');
|
||||
|
||||
const CONTAINER_MOTION_PROPS = {
|
||||
variants: {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
ease: 'easeOut',
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: 0.2,
|
||||
ease: 'easeOut',
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
initial: 'hidden',
|
||||
animate: 'visible',
|
||||
exit: 'hidden',
|
||||
};
|
||||
|
||||
interface ChatTranscriptProps {
|
||||
hidden?: boolean;
|
||||
messages?: ReceivedMessage[];
|
||||
}
|
||||
|
||||
export function ChatTranscript({
|
||||
hidden = false,
|
||||
messages = [],
|
||||
className,
|
||||
...props
|
||||
}: ChatTranscriptProps & Omit<HTMLMotionProps<'div'>, 'ref'>) {
|
||||
const { state: agentState } = useAgent();
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 bottom-[135px] flex w-full flex-col md:bottom-[170px]">
|
||||
<AnimatePresence>
|
||||
{!hidden && (
|
||||
<MotionContainer
|
||||
{...props}
|
||||
{...CONTAINER_MOTION_PROPS}
|
||||
className={cn('flex h-full w-full flex-col gap-4', className)}
|
||||
>
|
||||
<AgentChatTranscript
|
||||
agentState={agentState}
|
||||
messages={messages}
|
||||
className="mx-auto w-full max-w-2xl [&_.is-user>div]:rounded-[22px] [&>div>div]:px-4 [&>div>div]:pt-40 md:[&>div>div]:px-6"
|
||||
/>
|
||||
</MotionContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const MotionPanel = motion.create('div');
|
||||
|
||||
interface ImageCardProps {
|
||||
image: GeneratedImage;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
function ImageCard({ image, onDismiss }: ImageCardProps) {
|
||||
const src = image.imageUrl;
|
||||
|
||||
return (
|
||||
<MotionPanel
|
||||
key={image.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="bg-background border-input/50 relative overflow-hidden rounded-xl border shadow-xl"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={image.prompt} className="block max-h-[360px] w-full object-contain" />
|
||||
{image.prompt && (
|
||||
<div className="bg-background/80 px-3 py-1.5 backdrop-blur-sm">
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">{image.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className={cn(
|
||||
'bg-background/70 hover:bg-background absolute top-2 right-2 rounded-full p-1 backdrop-blur-sm transition-colors'
|
||||
)}
|
||||
aria-label="Dismiss image"
|
||||
>
|
||||
<XIcon className="text-muted-foreground size-3.5" />
|
||||
</button>
|
||||
</MotionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface GeneratedImagePanelProps {
|
||||
chatOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for images generated by the agent and shows the most recent one.
|
||||
* When chat is open, repositions to the top-right corner to avoid covering the transcript.
|
||||
*
|
||||
* HACK HERE: swap for a gallery view, a fullscreen modal, download button, etc.
|
||||
*/
|
||||
export function GeneratedImagePanel({ chatOpen = false }: GeneratedImagePanelProps) {
|
||||
const images = useGeneratedImages();
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
const prevLengthRef = useRef(0);
|
||||
|
||||
// Auto-scroll to latest image
|
||||
useEffect(() => {
|
||||
if (images.length > prevLengthRef.current) {
|
||||
prevLengthRef.current = images.length;
|
||||
}
|
||||
}, [images]);
|
||||
|
||||
const visible = images.filter((img) => !dismissed.has(img.id));
|
||||
const latest = visible.at(-1);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setDismissed((prev) => new Set([...prev, id]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-40 flex justify-center px-4',
|
||||
chatOpen
|
||||
? 'top-16 right-4 bottom-auto left-auto justify-end'
|
||||
: 'inset-x-0 bottom-36 md:bottom-44'
|
||||
)}
|
||||
>
|
||||
<div className={cn('pointer-events-auto', chatOpen ? 'w-48' : 'w-full max-w-sm')}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{latest && (
|
||||
<ImageCard key={latest.id} image={latest} onDismiss={() => dismiss(latest.id)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeftIcon, ImagesIcon, XIcon } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const MotionPanel = motion.create('div');
|
||||
const MotionOverlay = motion.create('div');
|
||||
|
||||
interface ThumbnailProps {
|
||||
image: GeneratedImage;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function Thumbnail({ image, onClick }: ThumbnailProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="group border-input/50 bg-muted hover:border-foreground/20 focus-visible:ring-ring relative aspect-square overflow-hidden rounded-lg border transition-all hover:shadow-md focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image.imageUrl}
|
||||
alt={image.prompt}
|
||||
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface FullImageViewProps {
|
||||
image: GeneratedImage;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function FullImageView({ image, onBack }: FullImageViewProps) {
|
||||
return (
|
||||
<MotionPanel
|
||||
key="full"
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 24 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground hover:text-foreground mb-3 flex items-center gap-1 text-sm transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
All images
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.imageUrl} alt={image.prompt} className="w-full rounded-xl object-contain" />
|
||||
{image.prompt && <p className="text-muted-foreground mt-3 text-sm">{image.prompt}</p>}
|
||||
</MotionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating gallery button + slide-in panel for all agent-generated images.
|
||||
* The button appears once at least one image has been generated.
|
||||
*/
|
||||
export function ImageGallery() {
|
||||
const images = useGeneratedImages();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<GeneratedImage | null>(null);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating trigger button */}
|
||||
<AnimatePresence>
|
||||
{!open && (
|
||||
<MotionPanel
|
||||
key="trigger"
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.85 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="fixed right-4 bottom-36 z-40 md:bottom-44"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="View generated images"
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-full p-3',
|
||||
'bg-background border-input/50 border shadow-lg',
|
||||
'hover:bg-accent focus-visible:ring-ring transition-colors focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<ImagesIcon className="text-foreground size-5" />
|
||||
<span className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold">
|
||||
{images.length}
|
||||
</span>
|
||||
</button>
|
||||
</MotionPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Gallery panel + backdrop */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<MotionOverlay
|
||||
key="overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MotionPanel
|
||||
key="panel"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="border-input/50 bg-background fixed top-0 right-0 bottom-0 z-[60] flex w-80 flex-col overflow-hidden border-l shadow-2xl md:top-16"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-input/50 flex items-center justify-between border-b px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Generated images</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{images.length} {images.length === 1 ? 'image' : 'images'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
aria-label="Close gallery"
|
||||
className="hover:bg-accent focus-visible:ring-ring rounded-full p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<XIcon className="text-muted-foreground size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{selected ? (
|
||||
<FullImageView
|
||||
key={selected.id}
|
||||
image={selected}
|
||||
onBack={() => setSelected(null)}
|
||||
/>
|
||||
) : (
|
||||
<MotionPanel
|
||||
key="grid"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{[...images].reverse().map((img) => (
|
||||
<Thumbnail key={img.id} image={img} onClick={() => setSelected(img)} />
|
||||
))}
|
||||
</MotionPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MotionPanel>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useSessionContext, useSessionMessages } from '@livekit/components-react';
|
||||
import type { AppConfig } from '@/app-config';
|
||||
import {
|
||||
AgentControlBar,
|
||||
type AgentControlBarControls,
|
||||
} from '@/components/agents-ui/agent-control-bar';
|
||||
import { ChatTranscript } from '@/components/app/chat-transcript';
|
||||
import { GeneratedImagePanel } from '@/components/app/generated-image-panel';
|
||||
import { ImageGallery } from '@/components/app/image-gallery';
|
||||
import { TileLayout } from '@/components/app/tile-layout';
|
||||
import { GeneratedImagesProvider } from '@/hooks/useGeneratedImages';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
import { Shimmer } from '../ai-elements/shimmer';
|
||||
|
||||
const MotionBottom = motion.create('div');
|
||||
|
||||
const MotionMessage = motion.create(Shimmer);
|
||||
|
||||
const BOTTOM_VIEW_MOTION_PROPS = {
|
||||
variants: {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
translateY: '0%',
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
translateY: '100%',
|
||||
},
|
||||
},
|
||||
initial: 'hidden',
|
||||
animate: 'visible',
|
||||
exit: 'hidden',
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
delay: 0.5,
|
||||
ease: 'easeOut',
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Fade({ top = false, bottom = false, className }: FadeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'from-background pointer-events-none h-4 bg-linear-to-b to-transparent',
|
||||
top && 'bg-linear-to-b',
|
||||
bottom && 'bg-linear-to-t',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionViewProps {
|
||||
appConfig: AppConfig;
|
||||
}
|
||||
|
||||
export const SessionView = ({
|
||||
appConfig,
|
||||
...props
|
||||
}: React.ComponentProps<'section'> & SessionViewProps) => {
|
||||
const session = useSessionContext();
|
||||
const { messages } = useSessionMessages(session);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const controls: AgentControlBarControls = {
|
||||
leave: true,
|
||||
microphone: true,
|
||||
chat: appConfig.supportsChatInput,
|
||||
camera: appConfig.supportsVideoInput,
|
||||
screenShare: appConfig.supportsScreenShare,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessageIsLocal = lastMessage?.from?.isLocal === true;
|
||||
|
||||
if (scrollAreaRef.current && lastMessageIsLocal) {
|
||||
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<section className="bg-background relative z-10 h-svh w-svw overflow-hidden" {...props}>
|
||||
<Fade top className="absolute inset-x-4 top-0 z-10 h-40" />
|
||||
{/* transcript */}
|
||||
<ChatTranscript
|
||||
hidden={!chatOpen}
|
||||
messages={messages}
|
||||
className="space-y-3 transition-opacity duration-300 ease-out"
|
||||
/>
|
||||
{/* Tile layout */}
|
||||
<TileLayout chatOpen={chatOpen} />
|
||||
{/* Single provider registers the byte stream handler once for both image components */}
|
||||
<GeneratedImagesProvider>
|
||||
{/* Generated image panel — appears when the agent calls generate_image */}
|
||||
<GeneratedImagePanel chatOpen={chatOpen} />
|
||||
{/* Gallery — persistent access to all generated images */}
|
||||
<ImageGallery />
|
||||
</GeneratedImagesProvider>
|
||||
{/* Bottom */}
|
||||
<MotionBottom
|
||||
{...BOTTOM_VIEW_MOTION_PROPS}
|
||||
className="fixed inset-x-3 bottom-0 z-50 md:inset-x-12"
|
||||
>
|
||||
{/* Pre-connect message */}
|
||||
{appConfig.isPreConnectBufferEnabled && (
|
||||
<AnimatePresence>
|
||||
{messages.length === 0 && (
|
||||
<MotionMessage
|
||||
key="pre-connect-message"
|
||||
duration={2}
|
||||
aria-hidden={messages.length > 0}
|
||||
{...SHIMMER_MOTION_PROPS}
|
||||
className="pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold"
|
||||
>
|
||||
Agent is listening, ask it a question
|
||||
</MotionMessage>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<div className="bg-background relative mx-auto max-w-2xl pb-3 md:pb-12">
|
||||
<Fade bottom className="absolute inset-x-0 top-0 h-4 -translate-y-full" />
|
||||
<AgentControlBar
|
||||
variant="livekit"
|
||||
controls={controls}
|
||||
isChatOpen={chatOpen}
|
||||
isConnected={session.isConnected}
|
||||
onDisconnect={session.end}
|
||||
onIsChatOpenChange={setChatOpen}
|
||||
/>
|
||||
</div>
|
||||
</MotionBottom>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground bg-background flex w-full flex-row justify-end divide-x overflow-hidden rounded-full border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Color scheme toggle</span>
|
||||
<button type="button" onClick={() => setTheme('dark')} className="cursor-pointer p-1 pl-1.5">
|
||||
<span className="sr-only">Enable dark color scheme</span>
|
||||
<MoonIcon
|
||||
suppressHydrationWarning
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={cn(theme !== 'dark' && 'opacity-25')}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('light')}
|
||||
className="cursor-pointer px-1.5 py-1"
|
||||
>
|
||||
<span className="sr-only">Enable light color scheme</span>
|
||||
<SunIcon
|
||||
suppressHydrationWarning
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={cn(theme !== 'light' && 'opacity-25')}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('system')}
|
||||
className="cursor-pointer p-1 pr-1.5"
|
||||
>
|
||||
<span className="sr-only">Enable system color scheme</span>
|
||||
<MonitorIcon
|
||||
suppressHydrationWarning
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={cn(theme !== 'system' && 'opacity-25')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Track } from 'livekit-client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
type TrackReference,
|
||||
VideoTrack,
|
||||
useLocalParticipant,
|
||||
useTracks,
|
||||
useVoiceAssistant,
|
||||
} from '@livekit/components-react';
|
||||
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const MotionContainer = motion.create('div');
|
||||
|
||||
const ANIMATION_TRANSITION = {
|
||||
type: 'spring',
|
||||
stiffness: 675,
|
||||
damping: 75,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
// GRID
|
||||
// 2 Columns x 3 Rows
|
||||
grid: [
|
||||
'h-full w-full',
|
||||
'grid gap-x-2 place-content-center',
|
||||
'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]',
|
||||
],
|
||||
// Agent
|
||||
// chatOpen: true,
|
||||
// hasSecondTile: true
|
||||
// layout: Column 1 / Row 1
|
||||
// align: x-end y-center
|
||||
agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'],
|
||||
// Agent
|
||||
// chatOpen: true,
|
||||
// hasSecondTile: false
|
||||
// layout: Column 1 / Row 1 / Column-Span 2
|
||||
// align: x-center y-center
|
||||
agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'],
|
||||
// Agent
|
||||
// chatOpen: false
|
||||
// layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3
|
||||
// align: x-center y-center
|
||||
agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'],
|
||||
// Second tile
|
||||
// chatOpen: true,
|
||||
// hasSecondTile: true
|
||||
// layout: Column 2 / Row 1
|
||||
// align: x-start y-center
|
||||
secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'],
|
||||
// Second tile
|
||||
// chatOpen: false,
|
||||
// hasSecondTile: false
|
||||
// layout: Column 2 / Row 2
|
||||
// align: x-end y-end
|
||||
secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'],
|
||||
};
|
||||
|
||||
export function useLocalTrackRef(source: Track.Source) {
|
||||
const { localParticipant } = useLocalParticipant();
|
||||
const publication = localParticipant.getTrackPublication(source);
|
||||
const trackRef = useMemo<TrackReference | undefined>(
|
||||
() => (publication ? { source, participant: localParticipant, publication } : undefined),
|
||||
[source, publication, localParticipant]
|
||||
);
|
||||
return trackRef;
|
||||
}
|
||||
|
||||
interface TileLayoutProps {
|
||||
chatOpen: boolean;
|
||||
}
|
||||
|
||||
export function TileLayout({ chatOpen }: TileLayoutProps) {
|
||||
const {
|
||||
state: agentState,
|
||||
audioTrack: agentAudioTrack,
|
||||
videoTrack: agentVideoTrack,
|
||||
} = useVoiceAssistant();
|
||||
const [screenShareTrack] = useTracks([Track.Source.ScreenShare]);
|
||||
const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera);
|
||||
|
||||
const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted;
|
||||
const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted;
|
||||
const hasSecondTile = isCameraEnabled || isScreenShareEnabled;
|
||||
|
||||
const animationDelay = chatOpen ? 0 : 0.15;
|
||||
const isAvatar = agentVideoTrack !== undefined;
|
||||
const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0;
|
||||
const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-8 bottom-32 z-50 md:top-12 md:bottom-40">
|
||||
<div className="relative mx-auto h-full max-w-2xl px-4 md:px-0">
|
||||
<div className={cn(classNames.grid)}>
|
||||
{/* Agent */}
|
||||
<div
|
||||
className={cn([
|
||||
'grid',
|
||||
!chatOpen && classNames.agentChatClosed,
|
||||
chatOpen && hasSecondTile && classNames.agentChatOpenWithSecondTile,
|
||||
chatOpen && !hasSecondTile && classNames.agentChatOpenWithoutSecondTile,
|
||||
])}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{!isAvatar && (
|
||||
// Audio Agent
|
||||
<MotionContainer
|
||||
key="agent"
|
||||
layoutId="agent"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: chatOpen ? 1 : 4,
|
||||
}}
|
||||
transition={{
|
||||
...ANIMATION_TRANSITION,
|
||||
delay: animationDelay,
|
||||
}}
|
||||
className={cn(
|
||||
'bg-background aspect-square h-[90px] rounded-md border border-transparent transition-[border,drop-shadow]',
|
||||
chatOpen && 'border-input/50 drop-shadow-lg/10 delay-200'
|
||||
)}
|
||||
>
|
||||
<AgentAudioVisualizerBar
|
||||
barCount={5}
|
||||
state={agentState}
|
||||
audioTrack={agentAudioTrack}
|
||||
className={cn('flex h-full items-center justify-center gap-1 px-4 py-2')}
|
||||
>
|
||||
<span
|
||||
className={cn([
|
||||
'bg-muted min-h-2.5 w-2.5 rounded-full',
|
||||
'origin-center transition-colors duration-250 ease-linear',
|
||||
'data-[lk-highlighted=true]:bg-foreground data-[lk-muted=true]:bg-muted',
|
||||
])}
|
||||
/>
|
||||
</AgentAudioVisualizerBar>
|
||||
</MotionContainer>
|
||||
)}
|
||||
|
||||
{isAvatar && (
|
||||
// Avatar Agent
|
||||
<MotionContainer
|
||||
key="avatar"
|
||||
layoutId="avatar"
|
||||
initial={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
maskImage:
|
||||
'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 20px, transparent 20px)',
|
||||
filter: 'blur(20px)',
|
||||
}}
|
||||
animate={{
|
||||
maskImage:
|
||||
'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 500px, transparent 500px)',
|
||||
filter: 'blur(0px)',
|
||||
borderRadius: chatOpen ? 6 : 12,
|
||||
}}
|
||||
transition={{
|
||||
...ANIMATION_TRANSITION,
|
||||
delay: animationDelay,
|
||||
maskImage: {
|
||||
duration: 1,
|
||||
},
|
||||
filter: {
|
||||
duration: 1,
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
'overflow-hidden bg-black drop-shadow-xl/80',
|
||||
chatOpen ? 'h-[90px]' : 'h-auto w-full'
|
||||
)}
|
||||
>
|
||||
<VideoTrack
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
trackRef={agentVideoTrack}
|
||||
className={cn(chatOpen && 'size-[90px] object-cover')}
|
||||
/>
|
||||
</MotionContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn([
|
||||
'grid',
|
||||
chatOpen && classNames.secondTileChatOpen,
|
||||
!chatOpen && classNames.secondTileChatClosed,
|
||||
])}
|
||||
>
|
||||
{/* Camera & Screen Share */}
|
||||
<AnimatePresence>
|
||||
{((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && (
|
||||
<MotionContainer
|
||||
key="camera"
|
||||
layout="position"
|
||||
layoutId="camera"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
}}
|
||||
transition={{
|
||||
...ANIMATION_TRANSITION,
|
||||
delay: animationDelay,
|
||||
}}
|
||||
className="drop-shadow-lg/20"
|
||||
>
|
||||
<VideoTrack
|
||||
trackRef={cameraTrack || screenShareTrack}
|
||||
width={(cameraTrack || screenShareTrack)?.publication.dimensions?.width ?? 0}
|
||||
height={(cameraTrack || screenShareTrack)?.publication.dimensions?.height ?? 0}
|
||||
className="bg-muted aspect-square w-[90px] rounded-md object-cover"
|
||||
/>
|
||||
</MotionContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useSessionContext } from '@livekit/components-react';
|
||||
import type { AppConfig } from '@/app-config';
|
||||
import { SessionView } from '@/components/app/session-view';
|
||||
import { WelcomeView } from '@/components/app/welcome-view';
|
||||
|
||||
const MotionWelcomeView = motion.create(WelcomeView);
|
||||
const MotionSessionView = motion.create(SessionView);
|
||||
|
||||
const VIEW_MOTION_PROPS = {
|
||||
variants: {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
initial: 'hidden',
|
||||
animate: 'visible',
|
||||
exit: 'hidden',
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'linear',
|
||||
},
|
||||
};
|
||||
|
||||
interface ViewControllerProps {
|
||||
appConfig: AppConfig;
|
||||
}
|
||||
|
||||
export function ViewController({ appConfig }: ViewControllerProps) {
|
||||
const { isConnected, start } = useSessionContext();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Welcome view */}
|
||||
{!isConnected && (
|
||||
<MotionWelcomeView
|
||||
key="welcome"
|
||||
{...VIEW_MOTION_PROPS}
|
||||
startButtonText={appConfig.startButtonText}
|
||||
onStartCall={start}
|
||||
/>
|
||||
)}
|
||||
{/* Session view */}
|
||||
{isConnected && (
|
||||
<MotionSessionView key="session-view" {...VIEW_MOTION_PROPS} appConfig={appConfig} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
function WelcomeImage() {
|
||||
return (
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-fg0 mb-4 size-16"
|
||||
>
|
||||
<path
|
||||
d="M15 24V40C15 40.7957 14.6839 41.5587 14.1213 42.1213C13.5587 42.6839 12.7956 43 12 43C11.2044 43 10.4413 42.6839 9.87868 42.1213C9.31607 41.5587 9 40.7957 9 40V24C9 23.2044 9.31607 22.4413 9.87868 21.8787C10.4413 21.3161 11.2044 21 12 21C12.7956 21 13.5587 21.3161 14.1213 21.8787C14.6839 22.4413 15 23.2044 15 24ZM22 5C21.2044 5 20.4413 5.31607 19.8787 5.87868C19.3161 6.44129 19 7.20435 19 8V56C19 56.7957 19.3161 57.5587 19.8787 58.1213C20.4413 58.6839 21.2044 59 22 59C22.7956 59 23.5587 58.6839 24.1213 58.1213C24.6839 57.5587 25 56.7957 25 56V8C25 7.20435 24.6839 6.44129 24.1213 5.87868C23.5587 5.31607 22.7956 5 22 5ZM32 13C31.2044 13 30.4413 13.3161 29.8787 13.8787C29.3161 14.4413 29 15.2044 29 16V48C29 48.7957 29.3161 49.5587 29.8787 50.1213C30.4413 50.6839 31.2044 51 32 51C32.7956 51 33.5587 50.6839 34.1213 50.1213C34.6839 49.5587 35 48.7957 35 48V16C35 15.2044 34.6839 14.4413 34.1213 13.8787C33.5587 13.3161 32.7956 13 32 13ZM42 21C41.2043 21 40.4413 21.3161 39.8787 21.8787C39.3161 22.4413 39 23.2044 39 24V40C39 40.7957 39.3161 41.5587 39.8787 42.1213C40.4413 42.6839 41.2043 43 42 43C42.7957 43 43.5587 42.6839 44.1213 42.1213C44.6839 41.5587 45 40.7957 45 40V24C45 23.2044 44.6839 22.4413 44.1213 21.8787C43.5587 21.3161 42.7957 21 42 21ZM52 17C51.2043 17 50.4413 17.3161 49.8787 17.8787C49.3161 18.4413 49 19.2044 49 20V44C49 44.7957 49.3161 45.5587 49.8787 46.1213C50.4413 46.6839 51.2043 47 52 47C52.7957 47 53.5587 46.6839 54.1213 46.1213C54.6839 45.5587 55 44.7957 55 44V20C55 19.2044 54.6839 18.4413 54.1213 17.8787C53.5587 17.3161 52.7957 17 52 17Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface WelcomeViewProps {
|
||||
startButtonText: string;
|
||||
onStartCall: () => void;
|
||||
}
|
||||
|
||||
export const WelcomeView = ({
|
||||
startButtonText,
|
||||
onStartCall,
|
||||
ref,
|
||||
}: React.ComponentProps<'div'> & WelcomeViewProps) => {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<section className="bg-background flex flex-col items-center justify-center text-center">
|
||||
<WelcomeImage />
|
||||
|
||||
<p className="text-foreground max-w-prose pt-1 leading-6 font-medium">
|
||||
Voice, vision, images, and music — powered by Gemini 3.1, NanoBanana 2, and Lyria
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={onStartCall}
|
||||
className="mt-6 w-64 rounded-full font-mono text-xs font-bold tracking-wider uppercase"
|
||||
>
|
||||
{startButtonText}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="fixed bottom-5 left-0 flex w-full items-center justify-center">
|
||||
<p className="text-muted-foreground max-w-prose pt-1 text-xs leading-5 font-normal text-pretty md:text-sm">
|
||||
New to LiveKit Agents? Check out the{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.livekit.io/agents/"
|
||||
className="underline"
|
||||
>
|
||||
LiveKit Agents docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user