diff --git a/app-config.ts b/app-config.ts index d41e8b52..87d97dea 100644 --- a/app-config.ts +++ b/app-config.ts @@ -1,22 +1,4 @@ -export interface AppConfig { - pageTitle: string; - pageDescription: string; - companyName: string; - - supportsChatInput: boolean; - supportsVideoInput: boolean; - supportsScreenShare: boolean; - isPreConnectBufferEnabled: boolean; - - logo: string; - startButtonText: string; - accent?: string; - logoDark?: string; - accentDark?: string; - - sandboxId?: string; - agentName?: string; -} +import type { AppConfig } from './lib/types'; export const APP_CONFIG_DEFAULTS: AppConfig = { companyName: 'LiveKit', @@ -33,4 +15,6 @@ export const APP_CONFIG_DEFAULTS: AppConfig = { logoDark: '/lk-logo-dark.svg', accentDark: '#1fd5f9', startButtonText: 'Start call', + + agentName: undefined, }; diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 1f56a3ff..25847feb 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,11 +1,11 @@ import { headers } from 'next/headers'; import { getAppConfig } from '@/lib/utils'; -interface LayoutProps { +interface AppLayoutProps { children: React.ReactNode; } -export default async function Layout({ children }: LayoutProps) { +export default async function AppLayout({ children }: AppLayoutProps) { const hdrs = await headers(); const { companyName, logo, logoDark } = await getAppConfig(hdrs); @@ -39,7 +39,6 @@ export default async function Layout({ children }: LayoutProps) { - {children} ); diff --git a/app/(app)/opengraph-image.tsx b/app/(app)/opengraph-image.tsx index 9fccff1e..a438c0b4 100644 --- a/app/(app)/opengraph-image.tsx +++ b/app/(app)/opengraph-image.tsx @@ -165,7 +165,7 @@ export default async function Image() { gap: 10, }} > - {/* eslint-disable-next-line jsx-a11y/alt-text */} + {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */} {/* logo */} @@ -179,7 +179,7 @@ export default async function Image() { gap: 10, }} > - {/* eslint-disable-next-line jsx-a11y/alt-text */} + {/* eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text */} {/* title */} diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index f4655546..eb0e9c56 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,5 +1,5 @@ import { headers } from 'next/headers'; -import { App } from '@/components/app/app'; +import { App } from '@/components/app'; import { getAppConfig } from '@/lib/utils'; export default async function Page() { diff --git a/app/api/connection-details/route.ts b/app/api/connection-details/route.ts index 8f2c4841..02d6542a 100644 --- a/app/api/connection-details/route.ts +++ b/app/api/connection-details/route.ts @@ -2,13 +2,6 @@ import { NextResponse } from 'next/server'; import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk'; import { RoomConfiguration } from '@livekit/protocol'; -type ConnectionDetails = { - serverUrl: string; - roomName: string; - participantName: string; - participantToken: string; -}; - // NOTE: you are expected to define the following environment variables in `.env.local`: const API_KEY = process.env.LIVEKIT_API_KEY; const API_SECRET = process.env.LIVEKIT_API_SECRET; @@ -17,6 +10,13 @@ const LIVEKIT_URL = process.env.LIVEKIT_URL; // don't cache the results export const revalidate = 0; +export type ConnectionDetails = { + serverUrl: string; + roomName: string; + participantName: string; + participantToken: string; +}; + export async function POST(req: Request) { try { if (LIVEKIT_URL === undefined) { diff --git a/app/components/Container.tsx b/app/components/Container.tsx new file mode 100644 index 00000000..8c47b46b --- /dev/null +++ b/app/components/Container.tsx @@ -0,0 +1,12 @@ +import { cn } from '@/lib/utils'; + +interface ContainerProps { + children: React.ReactNode; + className?: string; +} + +export function Container({ children, className }: ContainerProps) { + return ( +
{children}
+ ); +} diff --git a/app/components/Tabs.tsx b/app/components/Tabs.tsx new file mode 100644 index 00000000..8b7602da --- /dev/null +++ b/app/components/Tabs.tsx @@ -0,0 +1,33 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from '@/lib/utils'; + +export function Tabs() { + const pathname = usePathname(); + + return ( +
+ + Base components + + + LiveKit components + +
+ ); +} diff --git a/app/components/base/page.tsx b/app/components/base/page.tsx new file mode 100644 index 00000000..53792483 --- /dev/null +++ b/app/components/base/page.tsx @@ -0,0 +1,132 @@ +import { PlusIcon } from '@phosphor-icons/react/dist/ssr'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Toggle } from '@/components/ui/toggle'; +import { Container } from '../Container'; + +const buttonVariants = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const; +const toggleVariants = ['default', 'outline'] as const; +const alertVariants = ['default', 'destructive'] as const; + +export default function Base() { + return ( + <> + {/* Button */} + +

A button component.

+
+ {buttonVariants.map((variant) => ( +
+

{variant}

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ + {/* Toggle */} + +

A toggle component.

+
+ {toggleVariants.map((variant) => ( +
+

{variant}

+
+
+ + Size sm + +
+
+ + Size default + +
+
+ + Size lg + +
+
+
+ ))} +
+
+ + {/* Alert */} + +

An alert component.

+
+ {alertVariants.map((variant) => ( +
+

{variant}

+ + Alert {variant} title + This is a {variant} alert description. + +
+ ))} +
+
+ + {/* Select */} + +

A select component.

+
+
+

Size default

+ +
+
+

Size sm

+ +
+
+
+ + ); +} diff --git a/app/components/layout.tsx b/app/components/layout.tsx new file mode 100644 index 00000000..a9240aef --- /dev/null +++ b/app/components/layout.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { headers } from 'next/headers'; +import { Tabs } from '@/app/components/Tabs'; +import { Provider } from '@/components/provider'; +import { cn, getAppConfig } from '@/lib/utils'; + +export default async function ComponentsLayout({ children }: { children: React.ReactNode }) { + const hdrs = await headers(); + const appConfig = await getAppConfig(hdrs); + return ( +
+
+

Quick Start UI overview

+

+ A quick start UI overview for the LiveKit Voice Assistant. +

+
+ + +
{children}
+
+
+ ); +} diff --git a/app/components/livekit/page.tsx b/app/components/livekit/page.tsx new file mode 100644 index 00000000..2905fe4d --- /dev/null +++ b/app/components/livekit/page.tsx @@ -0,0 +1,66 @@ +import { Track } from 'livekit-client'; +import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar'; +import { DeviceSelect } from '@/components/livekit/device-select'; +import { TrackToggle } from '@/components/livekit/track-toggle'; +import { Container } from '../Container'; + +export default function LiveKit() { + return ( + <> + {/* Device select */} + +
+

A device select component.

+
+
+
+

Size default

+ +
+
+

Size sm

+ +
+
+
+ + {/* Track toggle */} + +
+

A track toggle component.

+
+
+
+

+ Track.Source.Microphone +

+ +
+
+

+ Track.Source.Camera +

+ +
+
+
+ + {/* Agent control bar */} + +
+

A control bar component.

+
+
+ +
+
+ + ); +} diff --git a/app/components/page.tsx b/app/components/page.tsx new file mode 100644 index 00000000..eadebf51 --- /dev/null +++ b/app/components/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Components() { + return redirect('/components/base'); +} diff --git a/fonts/CommitMono-400-Italic.otf b/app/fonts/CommitMono-400-Italic.otf similarity index 100% rename from fonts/CommitMono-400-Italic.otf rename to app/fonts/CommitMono-400-Italic.otf diff --git a/fonts/CommitMono-400-Regular.otf b/app/fonts/CommitMono-400-Regular.otf similarity index 100% rename from fonts/CommitMono-400-Regular.otf rename to app/fonts/CommitMono-400-Regular.otf diff --git a/fonts/CommitMono-700-Italic.otf b/app/fonts/CommitMono-700-Italic.otf similarity index 100% rename from fonts/CommitMono-700-Italic.otf rename to app/fonts/CommitMono-700-Italic.otf diff --git a/fonts/CommitMono-700-Regular.otf b/app/fonts/CommitMono-700-Regular.otf similarity index 100% rename from fonts/CommitMono-700-Regular.otf rename to app/fonts/CommitMono-700-Regular.otf diff --git a/styles/globals.css b/app/globals.css similarity index 62% rename from styles/globals.css rename to app/globals.css index a1b5f7d4..036b3940 100644 --- a/styles/globals.css +++ b/app/globals.css @@ -4,6 +4,33 @@ @custom-variant dark (&:is(.dark *)); :root { + --fg0: #000000; + --fg1: #3b3b3b; + --fg2: #4d4d4d; + --fg3: #636363; + --fg4: #707070; + --fgSerious: #db1b06; + --fgSuccess: #006430; + --fgModerate: #a65006; + --fgAccent: #002cf2; + + --bg1: #f9f9f6; + --bg2: #f3f3f1; + --bg3: #e2e2df; + --bgSerious: #fae6e6; + --bgSerious2: #ffcdc7; + --bgSuccess: #d1fadf; + --bgModerate: #faedd1; + --bgAccent: #b3ccff; + --bgAccentPrimary: #e2ebfd; + + --separator1: #dbdbd8; + --separator2: #bdbdbb; + --separatorSerious: #ffcdc7; + --separatorSuccess: #94dcb5; + --separatorModerate: #fbd7a0; + --separatorAccent: #b3ccff; + --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); @@ -11,15 +38,15 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --primary: #002cf2; + --primary-hover: #0020b9; --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); + --muted: #f3f3f1; --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -39,21 +66,48 @@ } .dark { + --fg0: #ffffff; + --fg1: #cccccc; + --fg2: #b2b2b2; + --fg3: #999999; + --fg4: #666666; + --fgSerious: #ff7566; + --fgSuccess: #3bc981; + --fgModerate: #ffb752; + --fgAccent: #6e9dfe; + + --bg1: #070707; + --bg2: #131313; + --bg3: #202020; + --bgSerious: #1f0e0b; + --bgSerious2: #5a1c16; + --bgSuccess: #001905; + --bgModerate: #1a0e04; + --bgAccent: #090c17; + --bgAccentPrimary: #0c1640; + + --separator1: #202020; + --separator2: #30302f; + --separatorSerious: #5a1c16; + --separatorSuccess: #003213; + --separatorModerate: #3f2208; + --separatorAccent: #0c1640; + --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.269 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); + --primary: #1fd5f9; + --primary-hover: #19a7c7; --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); + --muted: #131313; --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.371 0 0); --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); @@ -73,12 +127,31 @@ } @theme inline { - --font-sans: - var(--font-public-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-mono: - var(--font-commit-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - 'Liberation Mono', 'Courier New', monospace; + --color-fg1: var(--fg1); + --color-fg2: var(--fg2); + --color-fg3: var(--fg3); + --color-fg4: var(--fg4); + --color-fgSerious: var(--fgSerious); + --color-fgSuccess: var(--fgSuccess); + --color-fgModerate: var(--fgModerate); + --color-fgAccent: var(--fgAccent); + + --color-bg1: var(--bg1); + --color-bg2: var(--bg2); + --color-bg3: var(--bg3); + --color-bgSerious: var(--bgSerious); + --color-bgSerious2: var(--bgSerious2); + --color-bgSuccess: var(--bgSuccess); + --color-bgModerate: var(--bgModerate); + --color-bgAccent: var(--bgAccent); + --color-bgAccentPrimary: var(--bgAccentPrimary); + + --color-separator1: var(--separator1); + --color-separator2: var(--separator2); + --color-separatorSerious: var(--separatorSerious); + --color-separatorSuccess: var(--separatorSuccess); + --color-separatorModerate: var(--separatorModerate); + --color-separatorAccent: var(--separatorAccent); --color-background: var(--background); --color-foreground: var(--foreground); @@ -87,6 +160,7 @@ --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); @@ -95,7 +169,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); - --color-border: var(--border); + --color-border: var(--separator1); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); @@ -111,11 +185,23 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + + --color-button: var(--bg2); + --color-button-hover: var(--bg3); + --color-button-foreground: var(--fg1); + --color-button-primary: var(--bg2); + --color-button-primary-foreground: var(--fgSerious); + --color-button-secondary: var(--bgAccentPrimary); + --color-button-secondary-foreground: var(--fgAccent); + + --color-destructive: var(--bgSerious); + --color-destructive-hover: var(--bgSerious2); + --color-destructive-foreground: var(--fgSerious); } @layer base { * { - @apply border-foreground/20 outline-ring/50; + @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; diff --git a/app/layout.tsx b/app/layout.tsx index 171d4453..f43f71dd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,10 @@ import { Public_Sans } from 'next/font/google'; import localFont from 'next/font/local'; import { headers } from 'next/headers'; -import { ApplyThemeScript, ThemeToggle } from '@/components/app/theme-toggle'; -import { cn, getAppConfig, getStyles } from '@/lib/utils'; -import '@/styles/globals.css'; +import { APP_CONFIG_DEFAULTS } from '@/app-config'; +import { ApplyThemeScript, ThemeToggle } from '@/components/theme-toggle'; +import { getAppConfig } from '@/lib/utils'; +import './globals.css'; const publicSans = Public_Sans({ variable: '--font-public-sans', @@ -11,30 +12,29 @@ const publicSans = Public_Sans({ }); const commitMono = localFont({ - display: 'swap', - variable: '--font-commit-mono', src: [ { - path: '../fonts/CommitMono-400-Regular.otf', + path: './fonts/CommitMono-400-Regular.otf', weight: '400', style: 'normal', }, { - path: '../fonts/CommitMono-700-Regular.otf', + path: './fonts/CommitMono-700-Regular.otf', weight: '700', style: 'normal', }, { - path: '../fonts/CommitMono-400-Italic.otf', + path: './fonts/CommitMono-400-Italic.otf', weight: '400', style: 'italic', }, { - path: '../fonts/CommitMono-700-Italic.otf', + path: './fonts/CommitMono-700-Italic.otf', weight: '700', style: 'italic', }, ], + variable: '--font-commit-mono', }); interface RootLayoutProps { @@ -43,27 +43,32 @@ interface RootLayoutProps { export default async function RootLayout({ children }: RootLayoutProps) { const hdrs = await headers(); - const appConfig = await getAppConfig(hdrs); - const { pageTitle, pageDescription } = appConfig; - const styles = getStyles(appConfig); + const { accent, accentDark, pageTitle, pageDescription } = await getAppConfig(hdrs); + + // check provided accent colors against defaults, and apply styles if they differ (or in development mode) + // generate a hover color for the accent color by mixing it with 20% black + const styles = [ + process.env.NODE_ENV === 'development' || accent !== APP_CONFIG_DEFAULTS.accent + ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }` + : '', + process.env.NODE_ENV === 'development' || accentDark !== APP_CONFIG_DEFAULTS.accentDark + ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }` + : '', + ] + .filter(Boolean) + .join('\n'); return ( - + {styles && } {pageTitle} - + {children}
diff --git a/app/ui/layout.tsx b/app/ui/layout.tsx deleted file mode 100644 index c7202785..00000000 --- a/app/ui/layout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from 'react'; -import { headers } from 'next/headers'; -import { SessionProvider } from '@/components/app/session-provider'; -import { getAppConfig } from '@/lib/utils'; - -export default async function ComponentsLayout({ children }: { children: React.ReactNode }) { - const hdrs = await headers(); - const appConfig = await getAppConfig(hdrs); - - return ( - -
-
-
-

LiveKit UI

-

- A set of UI components for building LiveKit-powered voice experiences. -

-

- Built with{' '} - - Shadcn - - ,{' '} - - Motion - - , and{' '} - - LiveKit - - . -

-

Open Source.

-
- -
{children}
-
-
-
- ); -} diff --git a/app/ui/page.tsx b/app/ui/page.tsx deleted file mode 100644 index 83e1a7ba..00000000 --- a/app/ui/page.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { type VariantProps } from 'class-variance-authority'; -import { Track } from 'livekit-client'; -import { MicrophoneIcon } from '@phosphor-icons/react/dist/ssr'; -import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar'; -import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select'; -import { TrackSelector } from '@/components/livekit/agent-control-bar/track-selector'; -import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; -import { Alert, AlertDescription, AlertTitle, alertVariants } from '@/components/livekit/alert'; -import { AlertToast } from '@/components/livekit/alert-toast'; -import { Button, buttonVariants } from '@/components/livekit/button'; -import { ChatEntry } from '@/components/livekit/chat-entry'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/livekit/select'; -import { ShimmerText } from '@/components/livekit/shimmer-text'; -import { Toggle, toggleVariants } from '@/components/livekit/toggle'; -import { cn } from '@/lib/utils'; - -type toggleVariantsType = VariantProps['variant']; -type toggleVariantsSizeType = VariantProps['size']; -type buttonVariantsType = VariantProps['variant']; -type buttonVariantsSizeType = VariantProps['size']; -type alertVariantsType = VariantProps['variant']; - -interface ContainerProps { - componentName?: string; - children: React.ReactNode; - className?: string; -} - -function Container({ componentName, children, className }: ContainerProps) { - return ( -
-

- {componentName} -

-
- {children} -
-
- ); -} - -function StoryTitle({ children }: { children: React.ReactNode }) { - return

{children}

; -} - -export default function Base() { - return ( - <> -

Primitives

- - {/* Button */} - - - - - - - - - - - - - {['default', 'primary', 'secondary', 'outline', 'ghost', 'link', 'destructive'].map( - (variant) => ( - - - {['sm', 'default', 'lg', 'icon'].map((size) => ( - - ))} - - ) - )} - -
SmallDefaultLargeIcon
{variant} - -
-
- - {/* Toggle */} - - - - - - - - - - - - - {['default', 'primary', 'secondary', 'outline'].map((variant) => ( - - - {['sm', 'default', 'lg', 'icon'].map((size) => ( - - ))} - - ))} - -
SmallDefaultLargeIcon
{variant} - - {size === 'icon' ? : 'Toggle'} - -
-
- - {/* Alert */} - - {['default', 'destructive'].map((variant) => ( -
- {variant} - - Alert {variant} title - This is a {variant} alert description. - -
- ))} -
- - {/* Select */} - -
-
- Size default - -
-
- Size sm - -
-
-
- -

Components

- - {/* Agent control bar */} - -
- -
-
- - {/* Track device select */} - -
-
- Size default - -
-
- Size sm - -
-
-
- - {/* Track toggle */} - -
-
- Track.Source.Microphone - -
-
- Track.Source.Camera - -
-
-
- - {/* Track selector */} - -
-
- Track.Source.Camera - -
-
- Track.Source.Microphone - -
-
-
- - {/* Chat entry */} - -
- - -
-
- - {/* Shimmer text */} - -
- This is shimmer text -
-
- - {/* Alert toast */} - - Alert toast -
- -
-
- - ); -} diff --git a/components/livekit/alert-toast.tsx b/components/alert-toast.tsx similarity index 85% rename from components/livekit/alert-toast.tsx rename to components/alert-toast.tsx index 0d09ccb8..f9f81f58 100644 --- a/components/livekit/alert-toast.tsx +++ b/components/alert-toast.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react'; import { toast as sonnerToast } from 'sonner'; import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; -import { Alert, AlertDescription, AlertTitle } from '@/components/livekit/alert'; +import { Alert, AlertDescription, AlertTitle } from './ui/alert'; interface ToastProps { id: string | number; @@ -18,7 +18,7 @@ export function toastAlert(toast: Omit) { ); } -export function AlertToast(props: ToastProps) { +function AlertToast(props: ToastProps) { const { title, description, id } = props; return ( diff --git a/components/app.tsx b/components/app.tsx new file mode 100644 index 00000000..5da7c44b --- /dev/null +++ b/components/app.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Room, RoomEvent } from 'livekit-client'; +import { motion } from 'motion/react'; +import { RoomAudioRenderer, RoomContext, StartAudio } from '@livekit/components-react'; +import { toastAlert } from '@/components/alert-toast'; +import { SessionView } from '@/components/session-view'; +import { Toaster } from '@/components/ui/sonner'; +import { Welcome } from '@/components/welcome'; +import useConnectionDetails from '@/hooks/useConnectionDetails'; +import type { AppConfig } from '@/lib/types'; + +const MotionWelcome = motion.create(Welcome); +const MotionSessionView = motion.create(SessionView); + +interface AppProps { + appConfig: AppConfig; +} + +export function App({ appConfig }: AppProps) { + const room = useMemo(() => new Room(), []); + const [sessionStarted, setSessionStarted] = useState(false); + const { refreshConnectionDetails, existingOrRefreshConnectionDetails } = + useConnectionDetails(appConfig); + + useEffect(() => { + const onDisconnected = () => { + setSessionStarted(false); + refreshConnectionDetails(); + }; + const onMediaDevicesError = (error: Error) => { + toastAlert({ + title: 'Encountered an error with your media devices', + description: `${error.name}: ${error.message}`, + }); + }; + room.on(RoomEvent.MediaDevicesError, onMediaDevicesError); + room.on(RoomEvent.Disconnected, onDisconnected); + return () => { + room.off(RoomEvent.Disconnected, onDisconnected); + room.off(RoomEvent.MediaDevicesError, onMediaDevicesError); + }; + }, [room, refreshConnectionDetails]); + + useEffect(() => { + let aborted = false; + if (sessionStarted && room.state === 'disconnected') { + Promise.all([ + room.localParticipant.setMicrophoneEnabled(true, undefined, { + preConnectBuffer: appConfig.isPreConnectBufferEnabled, + }), + existingOrRefreshConnectionDetails().then((connectionDetails) => + room.connect(connectionDetails.serverUrl, connectionDetails.participantToken) + ), + ]).catch((error) => { + if (aborted) { + // Once the effect has cleaned up after itself, drop any errors + // + // These errors are likely caused by this effect rerunning rapidly, + // resulting in a previous run `disconnect` running in parallel with + // a current run `connect` + return; + } + + toastAlert({ + title: 'There was an error connecting to the agent', + description: `${error.name}: ${error.message}`, + }); + }); + } + return () => { + aborted = true; + room.disconnect(); + }; + }, [room, sessionStarted, appConfig.isPreConnectBufferEnabled]); + + const { startButtonText } = appConfig; + + return ( +
+ setSessionStarted(true)} + disabled={sessionStarted} + initial={{ opacity: 1 }} + animate={{ opacity: sessionStarted ? 0 : 1 }} + transition={{ duration: 0.5, ease: 'linear', delay: sessionStarted ? 0 : 0.5 }} + /> + + + + + {/* --- */} + + + + +
+ ); +} diff --git a/components/app/app.tsx b/components/app/app.tsx deleted file mode 100644 index b390e062..00000000 --- a/components/app/app.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { RoomAudioRenderer, StartAudio } from '@livekit/components-react'; -import type { AppConfig } from '@/app-config'; -import { SessionProvider } from '@/components/app/session-provider'; -import { ViewController } from '@/components/app/view-controller'; -import { Toaster } from '@/components/livekit/toaster'; - -interface AppProps { - appConfig: AppConfig; -} - -export function App({ appConfig }: AppProps) { - return ( - -
- -
- - - -
- ); -} diff --git a/components/app/chat-transcript.tsx b/components/app/chat-transcript.tsx deleted file mode 100644 index b67d0a67..00000000 --- a/components/app/chat-transcript.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react'; -import { type ReceivedChatMessage } from '@livekit/components-react'; -import { ChatEntry } from '@/components/livekit/chat-entry'; - -const MotionContainer = motion.create('div'); -const MotionChatEntry = motion.create(ChatEntry); - -const CONTAINER_MOTION_PROPS = { - variants: { - hidden: { - opacity: 0, - transition: { - ease: 'easeOut', - duration: 0.3, - staggerChildren: 0.1, - staggerDirection: -1, - }, - }, - visible: { - opacity: 1, - transition: { - delay: 0.2, - ease: 'easeOut', - duration: 0.3, - stagerDelay: 0.2, - staggerChildren: 0.1, - staggerDirection: 1, - }, - }, - }, - initial: 'hidden', - animate: 'visible', - exit: 'hidden', -}; - -const MESSAGE_MOTION_PROPS = { - variants: { - hidden: { - opacity: 0, - translateY: 10, - }, - visible: { - opacity: 1, - translateY: 0, - }, - }, -}; - -interface ChatTranscriptProps { - hidden?: boolean; - messages?: ReceivedChatMessage[]; -} - -export function ChatTranscript({ - hidden = false, - messages = [], - ...props -}: ChatTranscriptProps & Omit, 'ref'>) { - return ( - - {!hidden && ( - - {messages.map(({ id, timestamp, from, message, editTimestamp }: ReceivedChatMessage) => { - const locale = navigator?.language ?? 'en-US'; - const messageOrigin = from?.isLocal ? 'local' : 'remote'; - const hasBeenEdited = !!editTimestamp; - - return ( - - ); - })} - - )} - - ); -} diff --git a/components/app/preconnect-message.tsx b/components/app/preconnect-message.tsx deleted file mode 100644 index 719c3813..00000000 --- a/components/app/preconnect-message.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import { AnimatePresence, motion } from 'motion/react'; -import { type ReceivedChatMessage } 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?: ReceivedChatMessage[]; - 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-provider.tsx b/components/app/session-provider.tsx deleted file mode 100644 index 1906f4ca..00000000 --- a/components/app/session-provider.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { createContext, useContext, useMemo } from 'react'; -import { RoomContext } from '@livekit/components-react'; -import { APP_CONFIG_DEFAULTS, type AppConfig } from '@/app-config'; -import { useRoom } from '@/hooks/useRoom'; - -const SessionContext = createContext<{ - appConfig: AppConfig; - isSessionActive: boolean; - startSession: () => void; - endSession: () => void; -}>({ - appConfig: APP_CONFIG_DEFAULTS, - isSessionActive: false, - startSession: () => {}, - endSession: () => {}, -}); - -interface SessionProviderProps { - appConfig: AppConfig; - children: React.ReactNode; -} - -export const SessionProvider = ({ appConfig, children }: SessionProviderProps) => { - const { room, isSessionActive, startSession, endSession } = useRoom(appConfig); - const contextValue = useMemo( - () => ({ appConfig, isSessionActive, startSession, endSession }), - [appConfig, isSessionActive, startSession, endSession] - ); - - return ( - - {children} - - ); -}; - -export function useSession() { - return useContext(SessionContext); -} diff --git a/components/app/session-view.tsx b/components/app/session-view.tsx deleted file mode 100644 index 10f6e857..00000000 --- a/components/app/session-view.tsx +++ /dev/null @@ -1,120 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { motion } from 'motion/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 { useChatMessages } from '@/hooks/useChatMessages'; -import { useConnectionTimeout } from '@/hooks/useConnectionTimout'; -import { useDebugMode } from '@/hooks/useDebug'; -import { cn } from '@/lib/utils'; -import { ScrollArea } from '../livekit/scroll-area/scroll-area'; - -const MotionBottom = motion.create('div'); - -const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production'; -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', - }, -}; - -interface FadeProps { - top?: boolean; - bottom?: boolean; - className?: string; -} - -export function Fade({ top = false, bottom = false, className }: FadeProps) { - return ( -
- ); -} -interface SessionViewProps { - appConfig: AppConfig; -} - -export const SessionView = ({ - appConfig, - ...props -}: React.ComponentProps<'section'> & SessionViewProps) => { - useConnectionTimeout(200_000); - useDebugMode({ enabled: IN_DEVELOPMENT }); - - const messages = useChatMessages(); - const [chatOpen, setChatOpen] = useState(false); - - const controls: ControlBarControls = { - leave: true, - microphone: true, - chat: appConfig.supportsChatInput, - camera: appConfig.supportsVideoInput, - screenShare: appConfig.supportsVideoInput, - }; - - return ( -
- {/* Chat Transcript */} -
- - - -
- - {/* Tile Layout */} - - - {/* Bottom */} - - {appConfig.isPreConnectBufferEnabled && ( - - )} -
- - -
-
-
- ); -}; diff --git a/components/app/tile-layout.tsx b/components/app/tile-layout.tsx deleted file mode 100644 index 33372276..00000000 --- a/components/app/tile-layout.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import React, { useMemo } from 'react'; -import { Track } from 'livekit-client'; -import { AnimatePresence, motion } from 'motion/react'; -import { - BarVisualizer, - type TrackReference, - VideoTrack, - useLocalParticipant, - useTracks, - useVoiceAssistant, -} from '@livekit/components-react'; -import { cn } from '@/lib/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( - () => (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 ( -
-
-
- {/* Agent */} -
- - {!isAvatar && ( - // Audio Agent - - - - - - )} - - {isAvatar && ( - // Avatar Agent - - - - )} - -
- -
- {/* Camera & Screen Share */} - - {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && ( - - - - )} - -
-
-
-
- ); -} diff --git a/components/app/view-controller.tsx b/components/app/view-controller.tsx deleted file mode 100644 index 4519c44f..00000000 --- a/components/app/view-controller.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import { useRef } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; -import { useRoomContext } from '@livekit/components-react'; -import { useSession } from '@/components/app/session-provider'; -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', - }, -}; - -export function ViewController() { - const room = useRoomContext(); - const isSessionActiveRef = useRef(false); - const { appConfig, isSessionActive, startSession } = useSession(); - - // animation handler holds a reference to stale isSessionActive value - isSessionActiveRef.current = isSessionActive; - - // disconnect room after animation completes - const handleAnimationComplete = () => { - if (!isSessionActiveRef.current && room.state !== 'disconnected') { - room.disconnect(); - } - }; - - return ( - - {/* Welcome screen */} - {!isSessionActive && ( - - )} - {/* Session view */} - {isSessionActive && ( - - )} - - ); -} diff --git a/components/app/welcome-view.tsx b/components/app/welcome-view.tsx deleted file mode 100644 index 6356d60d..00000000 --- a/components/app/welcome-view.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button } from '@/components/livekit/button'; - -function WelcomeImage() { - return ( - - - - ); -} - -interface WelcomeViewProps { - startButtonText: string; - onStartCall: () => void; -} - -export const WelcomeView = ({ - startButtonText, - onStartCall, - ref, -}: React.ComponentProps<'div'> & WelcomeViewProps) => { - return ( -
-
- - -

- Chat live with your voice AI agent -

- - -
- -
-

- Need help getting set up? Check out the{' '} - - Voice AI quickstart - - . -

-
-
- ); -}; diff --git a/components/livekit/agent-control-bar/agent-control-bar.tsx b/components/livekit/agent-control-bar/agent-control-bar.tsx index 1b53b1c4..6790feef 100644 --- a/components/livekit/agent-control-bar/agent-control-bar.tsx +++ b/components/livekit/agent-control-bar/agent-control-bar.tsx @@ -1,31 +1,26 @@ 'use client'; -import { type HTMLAttributes, useCallback, useState } from 'react'; +import * as React from 'react'; +import { useCallback } from 'react'; import { Track } from 'livekit-client'; -import { useChat, useRemoteParticipants } from '@livekit/components-react'; +import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react'; import { ChatTextIcon, PhoneDisconnectIcon } from '@phosphor-icons/react/dist/ssr'; -import { useSession } from '@/components/app/session-provider'; -import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; -import { Button } from '@/components/livekit/button'; -import { Toggle } from '@/components/livekit/toggle'; +import { ChatInput } from '@/components/livekit/chat/chat-input'; +import { Button } from '@/components/ui/button'; +import { Toggle } from '@/components/ui/toggle'; +import { AppConfig } from '@/lib/types'; import { cn } from '@/lib/utils'; -import { ChatInput } from './chat-input'; -import { UseInputControlsProps, useInputControls } from './hooks/use-input-controls'; -import { usePublishPermissions } from './hooks/use-publish-permissions'; -import { TrackSelector } from './track-selector'; - -export interface ControlBarControls { - leave?: boolean; - camera?: boolean; - microphone?: boolean; - screenShare?: boolean; - chat?: boolean; -} - -export interface AgentControlBarProps extends UseInputControlsProps { - controls?: ControlBarControls; - onDisconnect?: () => void; +import { DeviceSelect } from '../device-select'; +import { TrackToggle } from '../track-toggle'; +import { UseAgentControlBarProps, useAgentControlBar } from './hooks/use-agent-control-bar'; + +export interface AgentControlBarProps + extends React.HTMLAttributes, + UseAgentControlBarProps { + capabilities: Pick; onChatOpenChange?: (open: boolean) => void; + onSendMessage?: (message: string) => Promise; + onDisconnect?: () => void; onDeviceError?: (error: { source: Track.Source; error: Error }) => void; } @@ -35,137 +30,199 @@ export interface AgentControlBarProps extends UseInputControlsProps { export function AgentControlBar({ controls, saveUserChoices = true, + capabilities, className, + onSendMessage, + onChatOpenChange, onDisconnect, onDeviceError, - onChatOpenChange, ...props -}: AgentControlBarProps & HTMLAttributes) { - const { send } = useChat(); +}: AgentControlBarProps) { const participants = useRemoteParticipants(); - const [chatOpen, setChatOpen] = useState(false); - const publishPermissions = usePublishPermissions(); - const { isSessionActive, endSession } = useSession(); + const [chatOpen, setChatOpen] = React.useState(false); + const [isSendingMessage, setIsSendingMessage] = React.useState(false); + + const isAgentAvailable = participants.some((p) => p.isAgent); + const isInputDisabled = !chatOpen || !isAgentAvailable || isSendingMessage; + + const [isDisconnecting, setIsDisconnecting] = React.useState(false); const { micTrackRef, + visibleControls, cameraToggle, microphoneToggle, screenShareToggle, handleAudioDeviceChange, handleVideoDeviceChange, - handleMicrophoneDeviceSelectError, - handleCameraDeviceSelectError, - } = useInputControls({ onDeviceError, saveUserChoices }); + handleDisconnect, + } = useAgentControlBar({ + controls, + saveUserChoices, + }); const handleSendMessage = async (message: string) => { - await send(message); + setIsSendingMessage(true); + try { + await onSendMessage?.(message); + } finally { + setIsSendingMessage(false); + } }; - const handleToggleTranscript = useCallback( - (open: boolean) => { - setChatOpen(open); - onChatOpenChange?.(open); - }, - [onChatOpenChange, setChatOpen] - ); - - const handleDisconnect = useCallback(async () => { - endSession(); + const onLeave = async () => { + setIsDisconnecting(true); + await handleDisconnect(); + setIsDisconnecting(false); onDisconnect?.(); - }, [endSession, onDisconnect]); - - const visibleControls = { - leave: controls?.leave ?? true, - microphone: controls?.microphone ?? publishPermissions.microphone, - screenShare: controls?.screenShare ?? publishPermissions.screenShare, - camera: controls?.camera ?? publishPermissions.camera, - chat: controls?.chat ?? publishPermissions.data, }; - const isAgentAvailable = participants.some((p) => p.isAgent); + React.useEffect(() => { + onChatOpenChange?.(chatOpen); + }, [chatOpen, onChatOpenChange]); + + const onMicrophoneDeviceSelectError = useCallback( + (error: Error) => { + onDeviceError?.({ source: Track.Source.Microphone, error }); + }, + [onDeviceError] + ); + const onCameraDeviceSelectError = useCallback( + (error: Error) => { + onDeviceError?.({ source: Track.Source.Camera, error }); + }, + [onDeviceError] + ); return (
- {/* Chat Input */} - {visibleControls.chat && ( - + {capabilities.supportsChatInput && ( +
+
+ +
+
+
)} -
-
- {/* Toggle Microphone */} +
+
{visibleControls.microphone && ( - +
+ + + + + +
+ +
)} - {/* Toggle Camera */} - {visibleControls.camera && ( - + {capabilities.supportsVideoInput && visibleControls.camera && ( +
+ +
+ +
)} - {/* Toggle Screen Share */} - {visibleControls.screenShare && ( - + {capabilities.supportsScreenShare && visibleControls.screenShare && ( +
+ +
)} - {/* Toggle Transcript */} - - - + {visibleControls.chat && ( + + + + )}
- - {/* Disconnect */} {visibleControls.leave && ( - - - ); -} diff --git a/components/livekit/agent-control-bar/hooks/use-input-controls.ts b/components/livekit/agent-control-bar/hooks/use-agent-control-bar.ts similarity index 56% rename from components/livekit/agent-control-bar/hooks/use-input-controls.ts rename to components/livekit/agent-control-bar/hooks/use-agent-control-bar.ts index e89f173b..2c1495e8 100644 --- a/components/livekit/agent-control-bar/hooks/use-input-controls.ts +++ b/components/livekit/agent-control-bar/hooks/use-agent-control-bar.ts @@ -1,51 +1,63 @@ -import { useCallback, useMemo } from 'react'; +import * as React from 'react'; import { Track } from 'livekit-client'; import { type TrackReferenceOrPlaceholder, useLocalParticipant, usePersistentUserChoices, + useRoomContext, useTrackToggle, } from '@livekit/components-react'; +import { usePublishPermissions } from './use-publish-permissions'; -export interface UseInputControlsProps { +export interface ControlBarControls { + microphone?: boolean; + screenShare?: boolean; + chat?: boolean; + camera?: boolean; + leave?: boolean; +} + +export interface UseAgentControlBarProps { + controls?: ControlBarControls; saveUserChoices?: boolean; - onDisconnect?: () => void; onDeviceError?: (error: { source: Track.Source; error: Error }) => void; } -export interface UseInputControlsReturn { +export interface UseAgentControlBarReturn { micTrackRef: TrackReferenceOrPlaceholder; + visibleControls: ControlBarControls; microphoneToggle: ReturnType>; cameraToggle: ReturnType>; screenShareToggle: ReturnType>; + handleDisconnect: () => void; handleAudioDeviceChange: (deviceId: string) => void; handleVideoDeviceChange: (deviceId: string) => void; - handleMicrophoneDeviceSelectError: (error: Error) => void; - handleCameraDeviceSelectError: (error: Error) => void; } -export function useInputControls({ - saveUserChoices = true, - onDeviceError, -}: UseInputControlsProps = {}): UseInputControlsReturn { +export function useAgentControlBar(props: UseAgentControlBarProps = {}): UseAgentControlBarReturn { + const { controls, saveUserChoices = true } = props; + const visibleControls = { + leave: true, + ...controls, + }; const { microphoneTrack, localParticipant } = useLocalParticipant(); + const publishPermissions = usePublishPermissions(); + const room = useRoomContext(); const microphoneToggle = useTrackToggle({ source: Track.Source.Microphone, - onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }), + onDeviceError: (error) => props.onDeviceError?.({ source: Track.Source.Microphone, error }), }); - const cameraToggle = useTrackToggle({ source: Track.Source.Camera, - onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }), + onDeviceError: (error) => props.onDeviceError?.({ source: Track.Source.Camera, error }), }); - const screenShareToggle = useTrackToggle({ source: Track.Source.ScreenShare, - onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }), + onDeviceError: (error) => props.onDeviceError?.({ source: Track.Source.ScreenShare, error }), }); - const micTrackRef = useMemo(() => { + const micTrackRef = React.useMemo(() => { return { participant: localParticipant, source: Track.Source.Microphone, @@ -53,28 +65,41 @@ export function useInputControls({ }; }, [localParticipant, microphoneTrack]); + visibleControls.microphone ??= publishPermissions.microphone; + visibleControls.screenShare ??= publishPermissions.screenShare; + visibleControls.camera ??= publishPermissions.camera; + visibleControls.chat ??= publishPermissions.data; + const { saveAudioInputEnabled, - saveVideoInputEnabled, saveAudioInputDeviceId, + saveVideoInputEnabled, saveVideoInputDeviceId, - } = usePersistentUserChoices({ preventSave: !saveUserChoices }); + } = usePersistentUserChoices({ + preventSave: !saveUserChoices, + }); + + const handleDisconnect = React.useCallback(async () => { + if (room) { + await room.disconnect(); + } + }, [room]); - const handleAudioDeviceChange = useCallback( + const handleAudioDeviceChange = React.useCallback( (deviceId: string) => { saveAudioInputDeviceId(deviceId ?? 'default'); }, [saveAudioInputDeviceId] ); - const handleVideoDeviceChange = useCallback( + const handleVideoDeviceChange = React.useCallback( (deviceId: string) => { saveVideoInputDeviceId(deviceId ?? 'default'); }, [saveVideoInputDeviceId] ); - const handleToggleCamera = useCallback( + const handleToggleCamera = React.useCallback( async (enabled?: boolean) => { if (screenShareToggle.enabled) { screenShareToggle.toggle(false); @@ -83,39 +108,31 @@ export function useInputControls({ // persist video input enabled preference saveVideoInputEnabled(!cameraToggle.enabled); }, - [cameraToggle, screenShareToggle, saveVideoInputEnabled] + [cameraToggle.enabled, screenShareToggle.enabled] ); - const handleToggleMicrophone = useCallback( + const handleToggleMicrophone = React.useCallback( async (enabled?: boolean) => { await microphoneToggle.toggle(enabled); // persist audio input enabled preference saveAudioInputEnabled(!microphoneToggle.enabled); }, - [microphoneToggle, saveAudioInputEnabled] + [microphoneToggle.enabled] ); - const handleToggleScreenShare = useCallback( + const handleToggleScreenShare = React.useCallback( async (enabled?: boolean) => { if (cameraToggle.enabled) { cameraToggle.toggle(false); } await screenShareToggle.toggle(enabled); }, - [cameraToggle, screenShareToggle] - ); - const handleMicrophoneDeviceSelectError = useCallback( - (error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }), - [onDeviceError] - ); - - const handleCameraDeviceSelectError = useCallback( - (error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }), - [onDeviceError] + [screenShareToggle.enabled, cameraToggle.enabled] ); return { micTrackRef, + visibleControls, cameraToggle: { ...cameraToggle, toggle: handleToggleCamera, @@ -128,9 +145,8 @@ export function useInputControls({ ...screenShareToggle, toggle: handleToggleScreenShare, }, + handleDisconnect, handleAudioDeviceChange, handleVideoDeviceChange, - handleMicrophoneDeviceSelectError, - handleCameraDeviceSelectError, }; } diff --git a/components/livekit/agent-control-bar/track-selector.tsx b/components/livekit/agent-control-bar/track-selector.tsx deleted file mode 100644 index fdd0a3f7..00000000 --- a/components/livekit/agent-control-bar/track-selector.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { - BarVisualizer, - type TrackReferenceOrPlaceholder, - useTrackToggle, -} from '@livekit/components-react'; -import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select'; -import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; -import { cn } from '@/lib/utils'; - -interface TrackSelectorProps { - kind: MediaDeviceKind; - source: Parameters[0]['source']; - pressed?: boolean; - pending?: boolean; - disabled?: boolean; - className?: string; - audioTrackRef?: TrackReferenceOrPlaceholder; - onPressedChange?: (pressed: boolean) => void; - onMediaDeviceError?: (error: Error) => void; - onActiveDeviceChange?: (deviceId: string) => void; -} - -export function TrackSelector({ - kind, - source, - pressed, - pending, - disabled, - className, - audioTrackRef, - onPressedChange, - onMediaDeviceError, - onActiveDeviceChange, -}: TrackSelectorProps) { - return ( -
- - {audioTrackRef && ( - - - - )} - -
- -
- ); -} diff --git a/components/livekit/agent-tile.tsx b/components/livekit/agent-tile.tsx new file mode 100644 index 00000000..a23fa151 --- /dev/null +++ b/components/livekit/agent-tile.tsx @@ -0,0 +1,35 @@ +import { type AgentState, BarVisualizer, type TrackReference } from '@livekit/components-react'; +import { cn } from '@/lib/utils'; + +interface AgentAudioTileProps { + state: AgentState; + audioTrack: TrackReference; + className?: string; +} + +export const AgentTile = ({ + state, + audioTrack, + className, + ref, +}: React.ComponentProps<'div'> & AgentAudioTileProps) => { + return ( +
+ + + +
+ ); +}; diff --git a/components/livekit/avatar-tile.tsx b/components/livekit/avatar-tile.tsx new file mode 100644 index 00000000..7a7c7240 --- /dev/null +++ b/components/livekit/avatar-tile.tsx @@ -0,0 +1,24 @@ +import { type TrackReference, VideoTrack } from '@livekit/components-react'; +import { cn } from '@/lib/utils'; + +interface AgentAudioTileProps { + videoTrack: TrackReference; + className?: string; +} + +export const AvatarTile = ({ + videoTrack, + className, + ref, +}: React.ComponentProps<'div'> & AgentAudioTileProps) => { + return ( +
+ +
+ ); +}; diff --git a/components/livekit/chat-entry.tsx b/components/livekit/chat-entry.tsx deleted file mode 100644 index c9a7fbfa..00000000 --- a/components/livekit/chat-entry.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from 'react'; -import { cn } from '@/lib/utils'; - -export interface ChatEntryProps extends React.HTMLAttributes { - /** The locale to use for the timestamp. */ - locale: string; - /** The timestamp of the message. */ - timestamp: number; - /** The message to display. */ - message: string; - /** The origin of the message. */ - messageOrigin: 'local' | 'remote'; - /** The sender's name. */ - name?: string; - /** Whether the message has been edited. */ - hasBeenEdited?: boolean; -} - -export const ChatEntry = ({ - name, - locale, - timestamp, - message, - messageOrigin, - hasBeenEdited = false, - className, - ...props -}: ChatEntryProps) => { - const time = new Date(timestamp); - const title = time.toLocaleTimeString(locale, { timeStyle: 'full' }); - - return ( -
  • -
    - {name && {name}} - - {hasBeenEdited && '*'} - {time.toLocaleTimeString(locale, { timeStyle: 'short' })} - -
    - - {message} - -
  • - ); -}; diff --git a/components/livekit/chat/chat-entry.tsx b/components/livekit/chat/chat-entry.tsx new file mode 100644 index 00000000..1ad1ab84 --- /dev/null +++ b/components/livekit/chat/chat-entry.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import type { MessageFormatter, ReceivedChatMessage } from '@livekit/components-react'; +import { cn } from '@/lib/utils'; +import { useChatMessage } from './hooks/utils'; + +export interface ChatEntryProps extends React.HTMLAttributes { + /** The chat massage object to display. */ + entry: ReceivedChatMessage; + /** Hide sender name. Useful when displaying multiple consecutive chat messages from the same person. */ + hideName?: boolean; + /** Hide message timestamp. */ + hideTimestamp?: boolean; + /** An optional formatter for the message body. */ + messageFormatter?: MessageFormatter; +} + +export const ChatEntry = ({ + entry, + messageFormatter, + hideName, + hideTimestamp, + className, + ...props +}: ChatEntryProps) => { + const { message, hasBeenEdited, time, locale, name } = useChatMessage(entry, messageFormatter); + + const isUser = entry.from?.isLocal ?? false; + const messageOrigin = isUser ? 'remote' : 'local'; + + return ( +
  • + {(!hideTimestamp || !hideName || hasBeenEdited) && ( + + {!hideName && {name}} + + {!hideTimestamp && ( + + {hasBeenEdited && '*'} + {time.toLocaleTimeString(locale, { timeStyle: 'short' })} + + )} + + )} + + + {message} + +
  • + ); +}; diff --git a/components/livekit/chat/chat-input.tsx b/components/livekit/chat/chat-input.tsx new file mode 100644 index 00000000..c23a7536 --- /dev/null +++ b/components/livekit/chat/chat-input.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface ChatInputProps extends React.HTMLAttributes { + onSend?: (message: string) => void; + disabled?: boolean; +} + +export function ChatInput({ onSend, className, disabled, ...props }: ChatInputProps) { + const inputRef = useRef(null); + const [message, setMessage] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + props.onSubmit?.(e); + onSend?.(message); + setMessage(''); + }; + + const isDisabled = disabled || message.trim().length === 0; + + useEffect(() => { + if (disabled) return; + // when not disabled refocus on input + inputRef.current?.focus(); + }, [disabled]); + + return ( +
    + setMessage(e.target.value)} + className="flex-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> + +
    + ); +} diff --git a/components/livekit/chat/chat-message-view.tsx b/components/livekit/chat/chat-message-view.tsx new file mode 100644 index 00000000..f3ce24c1 --- /dev/null +++ b/components/livekit/chat/chat-message-view.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { type RefObject, useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; + +export function useAutoScroll(scrollContentContainerRef: RefObject) { + useEffect(() => { + function scrollToBottom() { + const { scrollingElement } = document; + + if (scrollingElement) { + scrollingElement.scrollTop = scrollingElement.scrollHeight; + } + } + + if (scrollContentContainerRef.current) { + const resizeObserver = new ResizeObserver(scrollToBottom); + + resizeObserver.observe(scrollContentContainerRef.current); + scrollToBottom(); + + return () => resizeObserver.disconnect(); + } + }, [scrollContentContainerRef]); +} +interface ChatProps extends React.HTMLAttributes { + children?: React.ReactNode; + className?: string; +} + +export const ChatMessageView = ({ className, children, ...props }: ChatProps) => { + const scrollContentRef = useRef(null); + + useAutoScroll(scrollContentRef); + + return ( +
    + {children} +
    + ); +}; diff --git a/components/livekit/chat/hooks/utils.ts b/components/livekit/chat/hooks/utils.ts new file mode 100644 index 00000000..26dbddd5 --- /dev/null +++ b/components/livekit/chat/hooks/utils.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +import type { MessageFormatter, ReceivedChatMessage } from '@livekit/components-react'; + +export const useChatMessage = (entry: ReceivedChatMessage, messageFormatter?: MessageFormatter) => { + const formattedMessage = React.useMemo(() => { + return messageFormatter ? messageFormatter(entry.message) : entry.message; + }, [entry.message, messageFormatter]); + const hasBeenEdited = !!entry.editTimestamp; + const time = new Date(entry.timestamp); + const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US'; + + const name = entry.from?.name && entry.from.name !== '' ? entry.from.name : entry.from?.identity; + + return { message: formattedMessage, hasBeenEdited, time, locale, name }; +}; diff --git a/components/livekit/agent-control-bar/track-device-select.tsx b/components/livekit/device-select.tsx similarity index 67% rename from components/livekit/agent-control-bar/track-device-select.tsx rename to components/livekit/device-select.tsx index f0373f55..c48eb50d 100644 --- a/components/livekit/agent-control-bar/track-device-select.tsx +++ b/components/livekit/device-select.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { cva } from 'class-variance-authority'; import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'; import { useMaybeRoomContext, useMediaDeviceSelect } from '@livekit/components-react'; @@ -10,21 +10,25 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '@/components/livekit/select'; +} from '@/components/ui/select'; import { cn } from '@/lib/utils'; type DeviceSelectProps = React.ComponentProps & { kind: MediaDeviceKind; - variant?: 'default' | 'small'; track?: LocalAudioTrack | LocalVideoTrack | undefined; requestPermissions?: boolean; onMediaDeviceError?: (error: Error) => void; - onDeviceListChange?: (devices: MediaDeviceInfo[]) => void; + initialSelection?: string; onActiveDeviceChange?: (deviceId: string) => void; + onDeviceListChange?: (devices: MediaDeviceInfo[]) => void; + variant?: 'default' | 'small'; }; const selectVariants = cva( - 'w-full rounded-full px-3 py-2 text-sm cursor-pointer disabled:not-allowed', + [ + 'w-full rounded-full px-3 py-2 text-sm cursor-pointer', + 'disabled:not-allowed hover:bg-button-hover focus:bg-button-hover', + ], { variants: { size: { @@ -38,31 +42,30 @@ const selectVariants = cva( } ); -export function TrackDeviceSelect({ +export function DeviceSelect({ kind, track, - size = 'default', - requestPermissions = false, + requestPermissions, onMediaDeviceError, - onDeviceListChange, - onActiveDeviceChange, + // initialSelection, + // onActiveDeviceChange, + // onDeviceListChange, ...props }: DeviceSelectProps) { - const room = useMaybeRoomContext(); + const size = props.size || 'default'; + const [open, setOpen] = useState(false); const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions); + + const room = useMaybeRoomContext(); const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({ - room, kind, + room, track, requestPermissions: requestPermissionsState, onError: onMediaDeviceError, }); - useEffect(() => { - onDeviceListChange?.(devices); - }, [devices, onDeviceListChange]); - // When the select opens, ensure that media devices are re-requested in case when they were last // requested, permissions were not granted useLayoutEffect(() => { @@ -71,23 +74,12 @@ export function TrackDeviceSelect({ } }, [open]); - const handleActiveDeviceChange = (deviceId: string) => { - setActiveMediaDevice(deviceId); - onActiveDeviceChange?.(deviceId); - }; - - const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices]); - - if (filteredDevices.length < 2) { - return null; - } - return ( ); diff --git a/components/livekit/media-tiles.tsx b/components/livekit/media-tiles.tsx new file mode 100644 index 00000000..7b7cedd6 --- /dev/null +++ b/components/livekit/media-tiles.tsx @@ -0,0 +1,213 @@ +import React, { useMemo } from 'react'; +import { Track } from 'livekit-client'; +import { AnimatePresence, motion } from 'motion/react'; +import { + type TrackReference, + useLocalParticipant, + useTracks, + useVoiceAssistant, +} from '@livekit/components-react'; +import { cn } from '@/lib/utils'; +import { AgentTile } from './agent-tile'; +import { AvatarTile } from './avatar-tile'; +import { VideoTile } from './video-tile'; + +const MotionVideoTile = motion.create(VideoTile); +const MotionAgentTile = motion.create(AgentTile); +const MotionAvatarTile = motion.create(AvatarTile); + +const animationProps = { + initial: { + opacity: 0, + scale: 0, + }, + animate: { + opacity: 1, + scale: 1, + }, + exit: { + opacity: 0, + scale: 0, + }, + 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( + () => (publication ? { source, participant: localParticipant, publication } : undefined), + [source, publication, localParticipant] + ); + return trackRef; +} + +interface MediaTilesProps { + chatOpen: boolean; +} + +export function MediaTiles({ chatOpen }: MediaTilesProps) { + 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 transition = { + ...animationProps.transition, + delay: chatOpen ? 0 : 0.15, // delay on close + }; + const agentAnimate = { + ...animationProps.animate, + scale: chatOpen ? 1 : 3, + transition, + }; + const avatarAnimate = { + ...animationProps.animate, + transition, + }; + const agentLayoutTransition = transition; + const avatarLayoutTransition = transition; + + const isAvatar = agentVideoTrack !== undefined; + + return ( +
    +
    +
    + {/* agent */} +
    + + {!isAvatar && ( + // audio-only agent + + )} + {isAvatar && ( + // avatar agent + video]:h-[90px] [&>video]:w-auto' : 'h-auto w-full' + )} + /> + )} + +
    + +
    + {/* camera */} + + {cameraTrack && isCameraEnabled && ( + + )} + {/* screen */} + {isScreenShareEnabled && ( + + )} + +
    +
    +
    +
    + ); +} diff --git a/components/livekit/scroll-area/hooks/useAutoScroll.ts b/components/livekit/scroll-area/hooks/useAutoScroll.ts deleted file mode 100644 index b78404f4..00000000 --- a/components/livekit/scroll-area/hooks/useAutoScroll.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect } from 'react'; - -const AUTO_SCROLL_THRESHOLD_PX = 50; - -export function useAutoScroll(scrollContentContainer?: Element | null) { - useEffect(() => { - function scrollToBottom() { - if (!scrollContentContainer) return; - - const distanceFromBottom = - scrollContentContainer.scrollHeight - - scrollContentContainer.clientHeight - - scrollContentContainer.scrollTop; - - if (distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX) { - scrollContentContainer.scrollTop = scrollContentContainer.scrollHeight; - } - } - - if (scrollContentContainer && scrollContentContainer.firstElementChild) { - const resizeObserver = new ResizeObserver(scrollToBottom); - - resizeObserver.observe(scrollContentContainer.firstElementChild); - scrollToBottom(); - - return () => resizeObserver.disconnect(); - } - }, [scrollContentContainer]); -} diff --git a/components/livekit/scroll-area/scroll-area.tsx b/components/livekit/scroll-area/scroll-area.tsx deleted file mode 100644 index 8868d06e..00000000 --- a/components/livekit/scroll-area/scroll-area.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { useRef } from 'react'; -import { useAutoScroll } from '@/components/livekit/scroll-area/hooks/useAutoScroll'; -import { cn } from '@/lib/utils'; - -interface ScrollAreaProps { - children?: React.ReactNode; -} - -export function ScrollArea({ - className, - children, -}: ScrollAreaProps & React.HTMLAttributes) { - const scrollContentRef = useRef(null); - - useAutoScroll(scrollContentRef.current); - - return ( -
    -
    {children}
    -
    - ); -} diff --git a/components/livekit/shimmer-text.tsx b/components/livekit/shimmer-text.tsx deleted file mode 100644 index a2a6905e..00000000 --- a/components/livekit/shimmer-text.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { cn } from '@/lib/utils'; - -interface ShimmerTextProps { - children: React.ReactNode; - className?: string; -} - -export function ShimmerText({ - children, - className, - ref, -}: ShimmerTextProps & React.RefAttributes) { - return ( - - {children} - - ); -} - -export default ShimmerText; diff --git a/components/livekit/agent-control-bar/track-toggle.tsx b/components/livekit/track-toggle.tsx similarity index 96% rename from components/livekit/agent-control-bar/track-toggle.tsx rename to components/livekit/track-toggle.tsx index e861d529..f205b263 100644 --- a/components/livekit/agent-control-bar/track-toggle.tsx +++ b/components/livekit/track-toggle.tsx @@ -11,9 +11,14 @@ import { VideoCameraIcon, VideoCameraSlashIcon, } from '@phosphor-icons/react/dist/ssr'; -import { Toggle } from '@/components/livekit/toggle'; +import { Toggle } from '@/components/ui/toggle'; import { cn } from '@/lib/utils'; +export type TrackToggleProps = React.ComponentProps & { + source: Parameters[0]['source']; + pending?: boolean; +}; + function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) { if (pending) { return SpinnerIcon; @@ -31,11 +36,6 @@ function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) } } -export type TrackToggleProps = React.ComponentProps & { - source: Parameters[0]['source']; - pending?: boolean; -}; - export function TrackToggle({ source, pressed, pending, className, ...props }: TrackToggleProps) { const IconComponent = getSourceIcon(source, pressed ?? false, pending); diff --git a/components/livekit/video-tile.tsx b/components/livekit/video-tile.tsx new file mode 100644 index 00000000..90fd3215 --- /dev/null +++ b/components/livekit/video-tile.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { motion } from 'motion/react'; +import { VideoTrack } from '@livekit/components-react'; +import { cn } from '@/lib/utils'; + +const MotionVideoTrack = motion.create(VideoTrack); + +export const VideoTile = ({ + trackRef, + className, + ref, +}: React.ComponentProps<'div'> & React.ComponentProps) => { + return ( +
    + +
    + ); +}; diff --git a/components/provider.tsx b/components/provider.tsx new file mode 100644 index 00000000..59b7ab71 --- /dev/null +++ b/components/provider.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import { Room } from 'livekit-client'; +import { RoomContext } from '@livekit/components-react'; +import { toastAlert } from '@/components/alert-toast'; +import useConnectionDetails from '@/hooks/useConnectionDetails'; +import { AppConfig } from '@/lib/types'; + +export function Provider({ + appConfig, + children, +}: { + appConfig: AppConfig; + children: React.ReactNode; +}) { + const { connectionDetails } = useConnectionDetails(appConfig); + const room = React.useMemo(() => new Room(), []); + + React.useEffect(() => { + if (room.state === 'disconnected' && connectionDetails) { + Promise.all([ + room.localParticipant.setMicrophoneEnabled(true, undefined, { + preConnectBuffer: true, + }), + room.connect(connectionDetails.serverUrl, connectionDetails.participantToken), + ]).catch((error) => { + toastAlert({ + title: 'There was an error connecting to the agent', + description: `${error.name}: ${error.message}`, + }); + }); + } + return () => { + room.disconnect(); + }; + }, [room, connectionDetails]); + + return {children}; +} diff --git a/components/session-view.tsx b/components/session-view.tsx new file mode 100644 index 00000000..2f9c9e9c --- /dev/null +++ b/components/session-view.tsx @@ -0,0 +1,178 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { + type AgentState, + type ReceivedChatMessage, + useRoomContext, + useVoiceAssistant, +} from '@livekit/components-react'; +import { toastAlert } from '@/components/alert-toast'; +import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar'; +import { ChatEntry } from '@/components/livekit/chat/chat-entry'; +import { ChatMessageView } from '@/components/livekit/chat/chat-message-view'; +import { MediaTiles } from '@/components/livekit/media-tiles'; +import useChatAndTranscription from '@/hooks/useChatAndTranscription'; +import { useDebugMode } from '@/hooks/useDebug'; +import type { AppConfig } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +function isAgentAvailable(agentState: AgentState) { + return agentState == 'listening' || agentState == 'thinking' || agentState == 'speaking'; +} + +interface SessionViewProps { + appConfig: AppConfig; + disabled: boolean; + sessionStarted: boolean; +} + +export const SessionView = ({ + appConfig, + disabled, + sessionStarted, + ref, +}: React.ComponentProps<'div'> & SessionViewProps) => { + const { state: agentState } = useVoiceAssistant(); + const [chatOpen, setChatOpen] = useState(false); + const { messages, send } = useChatAndTranscription(); + const room = useRoomContext(); + + useDebugMode({ + enabled: process.env.NODE_END !== 'production', + }); + + async function handleSendMessage(message: string) { + await send(message); + } + + useEffect(() => { + if (sessionStarted) { + const timeout = setTimeout(() => { + if (!isAgentAvailable(agentState)) { + const reason = + agentState === 'connecting' + ? 'Agent did not join the room. ' + : 'Agent connected but did not complete initializing. '; + + toastAlert({ + title: 'Session ended', + description: ( +

    + {reason} + + See quickstart guide + + . +

    + ), + }); + room.disconnect(); + } + }, 20_000); + + return () => clearTimeout(timeout); + } + }, [agentState, sessionStarted, room]); + + const { supportsChatInput, supportsVideoInput, supportsScreenShare } = appConfig; + const capabilities = { + supportsChatInput, + supportsVideoInput, + supportsScreenShare, + }; + + return ( +
    + +
    + + {messages.map((message: ReceivedChatMessage) => ( + + + + ))} + +
    +
    + +
    + {/* skrim */} +
    +
    + + + +
    + +
    + {appConfig.isPreConnectBufferEnabled && ( + 0 ? 0 : 0.8, + duration: messages.length > 0 ? 0.2 : 0.5, + }, + }} + aria-hidden={messages.length > 0} + className={cn( + 'absolute inset-x-0 -top-12 text-center', + sessionStarted && messages.length === 0 && 'pointer-events-none' + )} + > +

    + Agent is listening, ask it a question +

    +
    + )} + + +
    + {/* skrim */} +
    + +
    +
    + ); +}; diff --git a/components/app/theme-toggle.tsx b/components/theme-toggle.tsx similarity index 98% rename from components/app/theme-toggle.tsx rename to components/theme-toggle.tsx index ffefc0da..33bd83ff 100644 --- a/components/app/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react'; +import type { ThemeMode } from '@/lib/types'; import { THEME_MEDIA_QUERY, THEME_STORAGE_KEY, cn } from '@/lib/utils'; const THEME_SCRIPT = ` @@ -22,8 +23,6 @@ const THEME_SCRIPT = ` .replace(/\n/g, '') .replace(/\s+/g, ' '); -export type ThemeMode = 'dark' | 'light' | 'system'; - function applyTheme(theme: ThemeMode) { const doc = document.documentElement; diff --git a/components/livekit/alert.tsx b/components/ui/alert.tsx similarity index 90% rename from components/livekit/alert.tsx rename to components/ui/alert.tsx index 74e94ed9..75b58f69 100644 --- a/components/livekit/alert.tsx +++ b/components/ui/alert.tsx @@ -12,8 +12,8 @@ const alertVariants = cva( variant: { default: 'bg-card text-card-foreground', destructive: [ - 'text-destructive bg-destructive/10 border-destructive/20', - '[&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + 'text-destructive-foreground bg-destructive border-destructive-border', + '[&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/90', ], }, }, @@ -61,4 +61,4 @@ function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) ); } -export { Alert, AlertTitle, AlertDescription, alertVariants }; +export { Alert, AlertTitle, AlertDescription }; diff --git a/components/livekit/button.tsx b/components/ui/button.tsx similarity index 80% rename from components/livekit/button.tsx rename to components/ui/button.tsx index 3a6fb38c..3f44d32c 100644 --- a/components/livekit/button.tsx +++ b/components/ui/button.tsx @@ -15,19 +15,19 @@ const buttonVariants = cva( { variants: { variant: { - default: 'bg-muted text-foreground hover:bg-muted focus:bg-muted hover:bg-foreground/10', + default: 'bg-button text-button-foreground hover:bg-muted focus:bg-muted', destructive: [ - 'bg-destructive/10 text-destructive', - 'hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20', - 'dark:focus-visible:ring-destructive/40', + 'bg-destructive text-destructive-foreground', + 'hover:bg-destructive-hover focus:bg-destructive-hover focus-visible:ring-destructive-foreground/20', + 'dark:focus-visible:ring-destructive-foreground/40', ], outline: [ - 'border border-input bg-background', + 'border bg-background', 'hover:bg-accent hover:text-accent-foreground', 'dark:bg-input/30 dark:border-input dark:hover:bg-input/50', ], - primary: 'bg-primary text-primary-foreground hover:bg-primary/70 focus:bg-primary/70', - secondary: 'bg-foreground/15 text-secondary-foreground hover:bg-foreground/20', + primary: 'bg-primary text-primary-foreground hover:bg-primary-hover focus:bg-primary-hover', + secondary: 'bg-secondary text-secondary-foregroun hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, diff --git a/components/livekit/select.tsx b/components/ui/select.tsx similarity index 96% rename from components/livekit/select.tsx rename to components/ui/select.tsx index 92941de4..7d3e2c8e 100644 --- a/components/livekit/select.tsx +++ b/components/ui/select.tsx @@ -33,9 +33,9 @@ function SelectTrigger({ [ 'flex w-fit cursor-pointer items-center justify-between gap-2', 'rounded-full px-3 py-2 text-sm whitespace-nowrap', - 'bg-muted transition-[color,border,background-color]', + 'bg-button transition-[color,border,background-color]', 'disabled:cursor-not-allowed disabled:opacity-50', - 'focus-visible:border-ring focus-visible:ring-ring/50 hover:bg-foreground/10 focus:bg-foreground/10 outline-none focus-visible:ring-[3px]', + 'hover:bg-muted focus:bg-muted hover:text-muted-foreground focus:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', 'data-[placeholder]:text-muted-foreground', 'data-[size=default]:h-9 data-[size=sm]:h-8', diff --git a/components/livekit/toaster.tsx b/components/ui/sonner.tsx similarity index 89% rename from components/livekit/toaster.tsx rename to components/ui/sonner.tsx index 8a57d95e..76b13a36 100644 --- a/components/livekit/toaster.tsx +++ b/components/ui/sonner.tsx @@ -4,7 +4,7 @@ import { useTheme } from 'next-themes'; import { Toaster as Sonner, ToasterProps } from 'sonner'; import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; -export function Toaster({ ...props }: ToasterProps) { +const Toaster = ({ ...props }: ToasterProps) => { const { theme = 'system' } = useTheme(); return ( @@ -25,4 +25,6 @@ export function Toaster({ ...props }: ToasterProps) { {...props} /> ); -} +}; + +export { Toaster }; diff --git a/components/livekit/toggle.tsx b/components/ui/toggle.tsx similarity index 51% rename from components/livekit/toggle.tsx rename to components/ui/toggle.tsx index f228a1cf..e363fdd7 100644 --- a/components/livekit/toggle.tsx +++ b/components/ui/toggle.tsx @@ -9,30 +9,31 @@ const toggleVariants = cva( [ 'inline-flex items-center justify-center gap-2 rounded-full', 'text-sm font-medium whitespace-nowrap', - 'cursor-pointer outline-none transition-[color,box-shadow,background-color]', - 'hover:bg-muted hover:text-muted-foreground', - 'disabled:pointer-events-none disabled:opacity-50', - 'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', + 'cursor-pointer outline-none transition-[color,border,background-color]', 'focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring', - 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive ', + 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'disabled:pointer-events-none disabled:opacity-50 disabled:not-allowed', + 'data-[state=on]:bg-button-selected data-[state=on]:border-button-border-selected', "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0", ], { variants: { variant: { - default: 'bg-transparent', + default: + 'bg-button hover:bg-muted focus:bg-muted hover:text-muted-foreground focus:text-muted-foreground', primary: - 'bg-muted data-[state=on]:bg-muted hover:text-foreground text-destructive hover:text-foreground hover:bg-foreground/10 hover:data-[state=on]:bg-foreground/10', + 'text-fg1 bg-button hover:bg-button-hover focus:bg-button-hover data-[state=off]:bg-button-primary hover:data-[state=off]:bg-button-hover data-[state=off]:text-button-primary-foreground', secondary: - 'bg-muted data-[state=on]:bg-muted hover:text-foreground hover:bg-foreground/10 hover:data-[state=on]:bg-foreground/10 data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30 data-[state=on]:text-blue-700 dark:data-[state=on]:text-blue-300', - outline: - 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', + 'text-fg1 bg-button hover:bg-button-hover focus:bg-button-hover data-[state=on]:bg-button-secondary hover:data-[state=on]:bg-button-secondary data-[state=on]:text-button-secondary-foreground', + outline: [ + 'border border-button-border bg-button text-button-foreground', + 'hover:bg-background focus:bg-background', + ], }, size: { - default: 'h-9 px-4 py-2 has-[>svg]:px-3', - sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5', - lg: 'h-10 px-6 has-[>svg]:px-4', - icon: 'size-9', + default: 'h-9 px-2 min-w-9', + sm: 'h-8 px-1.5 min-w-8', + lg: 'h-10 px-2.5 min-w-10', }, }, defaultVariants: { diff --git a/components/welcome.tsx b/components/welcome.tsx new file mode 100644 index 00000000..f0516191 --- /dev/null +++ b/components/welcome.tsx @@ -0,0 +1,61 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface WelcomeProps { + disabled: boolean; + startButtonText: string; + onStartCall: () => void; +} + +export const Welcome = ({ + disabled, + startButtonText, + onStartCall, + ref, +}: React.ComponentProps<'div'> & WelcomeProps) => { + return ( +
    + + + + +

    + Chat live with your voice AI agent +

    + + +
    + ); +}; diff --git a/hooks/useChatMessages.ts b/hooks/useChatAndTranscription.ts similarity index 51% rename from hooks/useChatMessages.ts rename to hooks/useChatAndTranscription.ts index 5cad96a8..d556970f 100644 --- a/hooks/useChatMessages.ts +++ b/hooks/useChatAndTranscription.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { Room } from 'livekit-client'; import { type ReceivedChatMessage, type TextStreamData, @@ -7,25 +6,12 @@ import { useRoomContext, useTranscriptions, } from '@livekit/components-react'; +import { transcriptionToChatMessage } from '@/lib/utils'; -function transcriptionToChatMessage(textStream: TextStreamData, room: Room): ReceivedChatMessage { - return { - id: textStream.streamInfo.id, - timestamp: textStream.streamInfo.timestamp, - message: textStream.text, - from: - textStream.participantInfo.identity === room.localParticipant.identity - ? room.localParticipant - : Array.from(room.remoteParticipants.values()).find( - (p) => p.identity === textStream.participantInfo.identity - ), - }; -} - -export function useChatMessages() { +export default function useChatAndTranscription() { + const transcriptions: TextStreamData[] = useTranscriptions(); const chat = useChat(); const room = useRoomContext(); - const transcriptions: TextStreamData[] = useTranscriptions(); const mergedTranscriptions = useMemo(() => { const merged: Array = [ @@ -35,5 +21,5 @@ export function useChatMessages() { return merged.sort((a, b) => a.timestamp - b.timestamp); }, [transcriptions, chat.chatMessages, room]); - return mergedTranscriptions; + return { messages: mergedTranscriptions, send: chat.send }; } diff --git a/hooks/useConnectionDetails.ts b/hooks/useConnectionDetails.ts new file mode 100644 index 00000000..5534c423 --- /dev/null +++ b/hooks/useConnectionDetails.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; +import { decodeJwt } from 'jose'; +import { ConnectionDetails } from '@/app/api/connection-details/route'; +import { AppConfig } from '@/lib/types'; + +const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000; + +export default function useConnectionDetails(appConfig: AppConfig) { + // Generate room connection details, including: + // - A random Room name + // - A random Participant name + // - An Access Token to permit the participant to join the room + // - The URL of the LiveKit server to connect to + // + // In real-world application, you would likely allow the user to specify their + // own participant name, and possibly to choose from existing rooms to join. + + const [connectionDetails, setConnectionDetails] = useState(null); + + const fetchConnectionDetails = useCallback(async () => { + setConnectionDetails(null); + const url = new URL( + process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details', + window.location.origin + ); + + let data: ConnectionDetails; + try { + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Sandbox-Id': appConfig.sandboxId ?? '', + }, + body: JSON.stringify({ + room_config: appConfig.agentName + ? { + agents: [{ agent_name: appConfig.agentName }], + } + : undefined, + }), + }); + data = await res.json(); + } catch (error) { + console.error('Error fetching connection details:', error); + throw new Error('Error fetching connection details!'); + } + + setConnectionDetails(data); + return data; + }, []); + + useEffect(() => { + fetchConnectionDetails(); + }, [fetchConnectionDetails]); + + const isConnectionDetailsExpired = useCallback(() => { + const token = connectionDetails?.participantToken; + if (!token) { + return true; + } + + const jwtPayload = decodeJwt(token); + if (!jwtPayload.exp) { + return true; + } + const expiresAt = new Date(jwtPayload.exp * 1000 - ONE_MINUTE_IN_MILLISECONDS); + + const now = new Date(); + return expiresAt <= now; + }, [connectionDetails?.participantToken]); + + const existingOrRefreshConnectionDetails = useCallback(async () => { + if (isConnectionDetailsExpired() || !connectionDetails) { + return fetchConnectionDetails(); + } else { + return connectionDetails; + } + }, [connectionDetails, fetchConnectionDetails, isConnectionDetailsExpired]); + + return { + connectionDetails, + refreshConnectionDetails: fetchConnectionDetails, + existingOrRefreshConnectionDetails, + }; +} diff --git a/hooks/useConnectionTimout.tsx b/hooks/useConnectionTimout.tsx deleted file mode 100644 index 7a7c12f2..00000000 --- a/hooks/useConnectionTimout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from 'react'; -import { type AgentState, useRoomContext, useVoiceAssistant } from '@livekit/components-react'; -import { toastAlert } from '@/components/livekit/alert-toast'; - -function isAgentAvailable(agentState: AgentState) { - return agentState == 'listening' || agentState == 'thinking' || agentState == 'speaking'; -} - -export function useConnectionTimeout(timout = 20_000) { - const room = useRoomContext(); - const { state: agentState } = useVoiceAssistant(); - - useEffect(() => { - const timeout = setTimeout(() => { - if (!isAgentAvailable(agentState)) { - const reason = - agentState === 'connecting' - ? 'Agent did not join the room. ' - : 'Agent connected but did not complete initializing. '; - - toastAlert({ - title: 'Session ended', - description: ( -

    - {reason} - - See quickstart guide - - . -

    - ), - }); - - room.disconnect(); - } - }, timout); - - return () => clearTimeout(timeout); - }, [agentState, room, timout]); -} diff --git a/hooks/useDebug.ts b/hooks/useDebug.ts index b5b12878..9d4b7d53 100644 --- a/hooks/useDebug.ts +++ b/hooks/useDebug.ts @@ -15,11 +15,11 @@ export const useDebugMode = (options: { logLevel?: LogLevel; enabled?: boolean } setLogLevel(logLevel ?? 'debug'); - // @ts-expect-error this is a global variable + // @ts-expect-error window.__lk_room = room; return () => { - // @ts-expect-error this is a global variable + // @ts-expect-error window.__lk_room = undefined; setLogLevel('silent'); }; diff --git a/hooks/useRoom.ts b/hooks/useRoom.ts deleted file mode 100644 index 2fada6fb..00000000 --- a/hooks/useRoom.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Room, RoomEvent, TokenSource } from 'livekit-client'; -import { AppConfig } from '@/app-config'; -import { toastAlert } from '@/components/livekit/alert-toast'; - -export function useRoom(appConfig: AppConfig) { - const aborted = useRef(false); - const room = useMemo(() => new Room(), []); - const [isSessionActive, setIsSessionActive] = useState(false); - - useEffect(() => { - function onDisconnected() { - setIsSessionActive(false); - } - - function onMediaDevicesError(error: Error) { - toastAlert({ - title: 'Encountered an error with your media devices', - description: `${error.name}: ${error.message}`, - }); - } - - room.on(RoomEvent.Disconnected, onDisconnected); - room.on(RoomEvent.MediaDevicesError, onMediaDevicesError); - - return () => { - room.off(RoomEvent.Disconnected, onDisconnected); - room.off(RoomEvent.MediaDevicesError, onMediaDevicesError); - }; - }, [room]); - - useEffect(() => { - return () => { - aborted.current = true; - room.disconnect(); - }; - }, [room]); - - const startSession = useCallback(() => { - setIsSessionActive(true); - - const tokenSource = TokenSource.custom(async () => { - const url = new URL( - process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details', - window.location.origin - ); - - try { - const res = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Sandbox-Id': appConfig.sandboxId ?? '', - }, - body: JSON.stringify({ - room_config: appConfig.agentName - ? { - agents: [{ agent_name: appConfig.agentName }], - } - : undefined, - }), - }); - return await res.json(); - } catch (error) { - console.error('Error fetching connection details:', error); - throw new Error('Error fetching connection details!'); - } - }); - - if (room.state === 'disconnected') { - const { isPreConnectBufferEnabled } = appConfig; - Promise.all([ - room.localParticipant.setMicrophoneEnabled(true, undefined, { - preConnectBuffer: isPreConnectBufferEnabled, - }), - tokenSource - .fetch({ agentName: appConfig.agentName }) - .then((connectionDetails) => - room.connect(connectionDetails.serverUrl, connectionDetails.participantToken) - ), - ]).catch((error) => { - if (aborted.current) { - // Once the effect has cleaned up after itself, drop any errors - // - // These errors are likely caused by this effect rerunning rapidly, - // resulting in a previous run `disconnect` running in parallel with - // a current run `connect` - return; - } - - toastAlert({ - title: 'There was an error connecting to the agent', - description: `${error.name}: ${error.message}`, - }); - }); - } - }, [room, appConfig]); - - const endSession = useCallback(() => { - setIsSessionActive(false); - }, []); - - return { room, isSessionActive, startSession, endSession }; -} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 00000000..2dfccde3 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,36 @@ +import type { TranscriptionSegment } from 'livekit-client'; + +export interface CombinedTranscription extends TranscriptionSegment { + role: 'assistant' | 'user'; + receivedAtMediaTimestamp: number; + receivedAt: number; +} +export type ThemeMode = 'dark' | 'light' | 'system'; + +export interface AppConfig { + pageTitle: string; + pageDescription: string; + companyName: string; + + supportsChatInput: boolean; + supportsVideoInput: boolean; + supportsScreenShare: boolean; + isPreConnectBufferEnabled: boolean; + + logo: string; + startButtonText: string; + accent?: string; + logoDark?: string; + accentDark?: string; + + sandboxId?: string; + agentName?: string; +} + +export interface SandboxConfig { + [key: string]: + | { type: 'string'; value: string } + | { type: 'number'; value: number } + | { type: 'boolean'; value: boolean } + | null; +} diff --git a/lib/utils.ts b/lib/utils.ts index 7bae2d59..44737ed3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,8 +1,10 @@ import { cache } from 'react'; import { type ClassValue, clsx } from 'clsx'; +import { Room } from 'livekit-client'; import { twMerge } from 'tailwind-merge'; +import type { ReceivedChatMessage, TextStreamData } from '@livekit/components-react'; import { APP_CONFIG_DEFAULTS } from '@/app-config'; -import type { AppConfig } from '@/app-config'; +import type { AppConfig, SandboxConfig } from './types'; export const CONFIG_ENDPOINT = process.env.NEXT_PUBLIC_APP_CONFIG_ENDPOINT; export const SANDBOX_ID = process.env.SANDBOX_ID; @@ -10,18 +12,27 @@ export const SANDBOX_ID = process.env.SANDBOX_ID; export const THEME_STORAGE_KEY = 'theme-mode'; export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)'; -export interface SandboxConfig { - [key: string]: - | { type: 'string'; value: string } - | { type: 'number'; value: number } - | { type: 'boolean'; value: boolean } - | null; -} - export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export function transcriptionToChatMessage( + textStream: TextStreamData, + room: Room +): ReceivedChatMessage { + return { + id: textStream.streamInfo.id, + timestamp: textStream.streamInfo.timestamp, + message: textStream.text, + from: + textStream.participantInfo.identity === room.localParticipant.identity + ? room.localParticipant + : Array.from(room.remoteParticipants.values()).find( + (p) => p.identity === textStream.participantInfo.identity + ), + }; +} + // https://react.dev/reference/react/cache#caveats // > React will invalidate the cache for all memoized functions for each server request. export const getAppConfig = cache(async (headers: Headers): Promise => { @@ -64,25 +75,3 @@ export const getAppConfig = cache(async (headers: Headers): Promise = return APP_CONFIG_DEFAULTS; }); - -// check provided accent colors against defaults -// apply styles if they differ (or in development mode) -// generate a hover color for the accent color by mixing it with 20% black -export function getStyles(appConfig: AppConfig) { - const { accent, accentDark } = appConfig; - const hasCustomAccentColor = - process.env.NODE_ENV === 'development' || accent !== APP_CONFIG_DEFAULTS.accent; - const hasCustomAccentDarkColor = - process.env.NODE_ENV === 'development' || accentDark !== APP_CONFIG_DEFAULTS.accentDark; - - return [ - hasCustomAccentColor - ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }` - : '', - hasCustomAccentDarkColor - ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }` - : '', - ] - .filter(Boolean) - .join('\n'); -} diff --git a/package.json b/package.json index 0c43a95f..c8977fd2 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,20 @@ "format:check": "prettier --check ." }, "dependencies": { - "@livekit/components-react": "^2.9.15", + "@livekit/components-react": "^2.9.14", "@livekit/protocol": "^1.40.0", "@phosphor-icons/react": "^2.1.8", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toolbar": "^1.1.10", "buffer-image-size": "^0.6.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^6.0.12", - "livekit-client": "^2.15.8", + "livekit-client": "^2.15.5", "livekit-server-sdk": "^2.13.2", "mime": "^4.0.7", "motion": "^12.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a20acd1..3c97e0e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,20 @@ importers: .: dependencies: '@livekit/components-react': - specifier: ^2.9.15 - version: 2.9.15(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1) + specifier: ^2.9.14 + version: 2.9.14(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1) '@livekit/protocol': specifier: ^1.40.0 version: 1.41.0 '@phosphor-icons/react': specifier: ^2.1.8 version: 2.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.9 + version: 1.2.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-select': specifier: ^2.2.5 version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -26,6 +32,9 @@ importers: '@radix-ui/react-toggle': specifier: ^1.1.9 version: 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-toolbar': + specifier: ^1.1.10 + version: 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) buffer-image-size: specifier: ^0.6.4 version: 0.6.4 @@ -39,8 +48,8 @@ importers: specifier: ^6.0.12 version: 6.1.0 livekit-client: - specifier: ^2.15.8 - version: 2.15.8(@types/dom-mediacapture-record@1.0.22) + specifier: ^2.15.5 + version: 2.15.6(@types/dom-mediacapture-record@1.0.22) livekit-server-sdk: specifier: ^2.13.2 version: 2.13.3 @@ -207,6 +216,9 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.2': + resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -397,15 +409,15 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} - '@livekit/components-core@0.12.10': - resolution: {integrity: sha512-lSGci8c8IB/qCi42g1tzNtDGpnBWH1XSSk/OA9Lzk7vqOG0LlkwD3zXfBeKfO2eWFmYRfrZ2GD59GaH2NtTgag==} + '@livekit/components-core@0.12.9': + resolution: {integrity: sha512-bwrZsHf6GaHIO+lLyA6Yps1STTX9YIeL3ixwt+Ufi88OgkNYdp41Ug8oeVDlf7tzdxa+r3Xkfaj/qvIG84Yo6A==} engines: {node: '>=18'} peerDependencies: livekit-client: ^2.13.3 tslib: ^2.6.2 - '@livekit/components-react@2.9.15': - resolution: {integrity: sha512-b+gA0sRJHMsyr/BoMBoY1vSXQmP3h5NmxZTUt+VG8xjzCYDjmUuiDUrKVwMIUoy1vK9I6uNfo+hp6qbLo84jfQ==} + '@livekit/components-react@2.9.14': + resolution: {integrity: sha512-fQ3t4PdcM+AORo62FWmJcfqWe7ODwVaU4nsqxse+fp6L5a+0K2uMD7yQ2jrutXIaUQigU/opzTUxPcpdk9+0ow==} engines: {node: '>=18'} peerDependencies: '@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0 @@ -425,12 +437,12 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} + '@livekit/protocol@1.39.3': + resolution: {integrity: sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==} + '@livekit/protocol@1.41.0': resolution: {integrity: sha512-bozBB39VSbd0IjRBwShMlLskqkd9weWJNskaB1CVpcEO9UUI1gMwAtBJOKYblzZJT9kE1SJa3L4oWWwsZMzSXw==} - '@livekit/protocol@1.42.0': - resolution: {integrity: sha512-42sYSCay2PZrn5yHHt+O3RQpTElcTrA7bqg7iYbflUApeerA5tUCJDr8Z4abHsYHVKjqVUbkBq/TPmT3X6aYOQ==} - '@napi-rs/wasm-runtime@0.2.10': resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==} @@ -618,6 +630,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -644,6 +669,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -657,6 +695,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -670,6 +734,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -679,6 +756,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toggle@1.1.10': resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} peerDependencies: @@ -692,6 +782,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1876,8 +1979,8 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} - livekit-client@2.15.8: - resolution: {integrity: sha512-M+GnlmoY+JOfGGhDov5f4V273YZ9DuWFBaPwz42fliC3TsFTzEcJoRqqE7uLtEGAnloqbLPk+sIvW/XSU4Z4/Q==} + livekit-client@2.15.6: + resolution: {integrity: sha512-bLdNXklpMfWofw9pCF2XGyYA3OUddXXG4KY+gTN7dh+YvG7TX+YaP/Kt9ugdZ3KziQLqK2HG1ict4s7uD0JAiQ==} peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -2676,13 +2779,17 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@floating-ui/core@1.7.2': + dependencies: + '@floating-ui/utils': 0.2.10 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 '@floating-ui/dom@1.6.13': dependencies: - '@floating-ui/core': 1.7.3 + '@floating-ui/core': 1.7.2 '@floating-ui/utils': 0.2.10 '@floating-ui/dom@1.7.3': @@ -2833,38 +2940,38 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@livekit/components-core@0.12.10(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + '@livekit/components-core@0.12.9(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': dependencies: '@floating-ui/dom': 1.6.13 - livekit-client: 2.15.8(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.15.6(@types/dom-mediacapture-record@1.0.22) loglevel: 1.9.1 rxjs: 7.8.2 tslib: 2.8.1 - '@livekit/components-react@2.9.15(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1)': + '@livekit/components-react@2.9.14(@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22)))(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tslib@2.8.1)': dependencies: - '@livekit/components-core': 0.12.10(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + '@livekit/components-core': 0.12.9(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) clsx: 2.1.1 - livekit-client: 2.15.8(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.15.6(@types/dom-mediacapture-record@1.0.22) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) tslib: 2.8.1 usehooks-ts: 3.1.1(react@19.1.1) optionalDependencies: - '@livekit/krisp-noise-filter': 0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22)) + '@livekit/krisp-noise-filter': 0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22)) - '@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22))': + '@livekit/krisp-noise-filter@0.2.16(livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22))': dependencies: - livekit-client: 2.15.8(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.15.6(@types/dom-mediacapture-record@1.0.22) optional: true '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.41.0': + '@livekit/protocol@1.39.3': dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/protocol@1.42.0': + '@livekit/protocol@1.41.0': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -3006,6 +3113,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.12 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@floating-ui/react-dom': 2.1.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -3034,6 +3150,16 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1) @@ -3043,6 +3169,40 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/number': 1.1.1 @@ -3072,6 +3232,15 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1) @@ -3079,6 +3248,21 @@ snapshots: optionalDependencies: '@types/react': 19.1.12 + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3090,6 +3274,21 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)': dependencies: react: 19.1.1 @@ -4378,13 +4577,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 - livekit-client@2.15.8(@types/dom-mediacapture-record@1.0.22): + livekit-client@2.15.6(@types/dom-mediacapture-record@1.0.22): dependencies: '@livekit/mutex': 1.1.1 - '@livekit/protocol': 1.42.0 + '@livekit/protocol': 1.39.3 '@types/dom-mediacapture-record': 1.0.22 events: 3.3.0 - jose: 6.1.0 loglevel: 1.9.2 sdp-transform: 2.15.0 ts-debounce: 4.0.0