├── .env.example ├── .eslintrc.json ├── .github ├── assets │ ├── readme-hero-dark.webp │ ├── readme-hero-light.webp │ ├── screenshot-dark.webp │ ├── screenshot-light.webp │ ├── template-dark.webp │ ├── template-graphic.svg │ └── template-light.webp └── workflows │ ├── build-and-test.yaml │ └── sync-to-production.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── TEMPLATE.md ├── app-config.ts ├── app ├── (app) │ ├── layout.tsx │ ├── opengraph-image.tsx │ └── page.tsx ├── api │ └── connection-details │ │ └── route.ts ├── favicon.ico └── layout.tsx ├── components.json ├── components ├── app │ ├── app.tsx │ ├── chat-transcript.tsx │ ├── preconnect-message.tsx │ ├── session-view.tsx │ ├── theme-provider.tsx │ ├── theme-toggle.tsx │ ├── tile-layout.tsx │ ├── view-controller.tsx │ └── welcome-view.tsx └── livekit │ ├── agent-control-bar │ ├── agent-control-bar.tsx │ ├── chat-input.tsx │ ├── hooks │ │ ├── use-input-controls.ts │ │ └── use-publish-permissions.ts │ ├── track-device-select.tsx │ ├── track-selector.tsx │ └── track-toggle.tsx │ ├── alert-toast.tsx │ ├── alert.tsx │ ├── button.tsx │ ├── chat-entry.tsx │ ├── scroll-area │ ├── hooks │ │ └── useAutoScroll.ts │ └── scroll-area.tsx │ ├── select.tsx │ ├── shimmer-text.tsx │ ├── toaster.tsx │ └── toggle.tsx ├── eslint.config.mjs ├── fonts ├── CommitMono-400-Italic.otf ├── CommitMono-400-Regular.otf ├── CommitMono-700-Italic.otf └── CommitMono-700-Regular.otf ├── hooks ├── useAgentErrors.tsx └── useDebug.ts ├── lib └── utils.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── commit-mono-400-regular.woff ├── everett-light.woff ├── lk-logo-dark.svg ├── lk-logo.svg ├── lk-wordmark.svg └── opengraph-image-bg.png ├── renovate.json ├── styles └── globals.css ├── taskfile.yaml └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Enviroment variables needed to connect to the LiveKit server. 2 | LIVEKIT_API_KEY= 3 | LIVEKIT_API_SECRET= 4 | LIVEKIT_URL=wss://.livekit.cloud 5 | 6 | 7 | # Internally used environment variables 8 | NEXT_PUBLIC_APP_CONFIG_ENDPOINT= 9 | SANDBOX_ID= 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/assets/readme-hero-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/.github/assets/readme-hero-dark.webp -------------------------------------------------------------------------------- /.github/assets/readme-hero-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/.github/assets/readme-hero-light.webp -------------------------------------------------------------------------------- /.github/assets/screenshot-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/.github/assets/screenshot-dark.webp -------------------------------------------------------------------------------- /.github/assets/screenshot-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/.github/assets/screenshot-light.webp -------------------------------------------------------------------------------- /.github/assets/template-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/.github/assets/template-dark.webp -------------------------------------------------------------------------------- /.github/assets/template-graphic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /.github/assets/template-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/.github/assets/template-light.webp -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and Build 2 | permissions: 3 | contents: read 4 | pull-requests: read 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: pnpm/action-setup@v4 17 | - name: Use Node.js 22 18 | uses: actions/setup-node@v5 19 | with: 20 | node-version: 22 21 | cache: 'pnpm' 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | 26 | - name: ESLint 27 | run: pnpm lint 28 | 29 | - name: Prettier 30 | run: pnpm format:check 31 | 32 | - name: Ensure build succeeds 33 | run: pnpm build 34 | -------------------------------------------------------------------------------- /.github/workflows/sync-to-production.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/sync-to-production.yaml 2 | name: Sync main to sandbox-production 3 | 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | sync: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | - uses: livekit-examples/sandbox-deploy-action@v1 14 | with: 15 | production_branch: 'sandbox-production' 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | !.env.example 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | node_modules/ 4 | pnpm-lock.yaml 5 | .next/ 6 | .env* 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "semi": true, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "importOrder": [ 8 | "^react", 9 | "^next", 10 | "^next/(.*)$", 11 | "", 12 | "^@[^/](.*)$", 13 | "^@/(.*)$", 14 | "^[./]" 15 | ], 16 | "importOrderSeparation": false, 17 | "importOrderSortSpecifiers": true, 18 | "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LiveKit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agent Starter for React 2 | 3 | This is a starter template for [LiveKit Agents](https://docs.livekit.io/agents) that provides a simple voice interface using the [LiveKit JavaScript SDK](https://github.com/livekit/client-sdk-js). It supports [voice](https://docs.livekit.io/agents/start/voice-ai), [transcriptions](https://docs.livekit.io/agents/build/text/), and [virtual avatars](https://docs.livekit.io/agents/integrations/avatar). 4 | 5 | Also available for: 6 | [Android](https://github.com/livekit-examples/agent-starter-android) • [Flutter](https://github.com/livekit-examples/agent-starter-flutter) • [Swift](https://github.com/livekit-examples/agent-starter-swift) • [React Native](https://github.com/livekit-examples/agent-starter-react-native) 7 | 8 | 9 | 10 | 11 | App screenshot 12 | 13 | 14 | ### Features: 15 | 16 | - Real-time voice interaction with LiveKit Agents 17 | - Camera video streaming support 18 | - Screen sharing capabilities 19 | - Audio visualization and level monitoring 20 | - Virtual avatar integration 21 | - Light/dark theme switching with system preference detection 22 | - Customizable branding, colors, and UI text via configuration 23 | 24 | This template is built with Next.js and is free for you to use or modify as you see fit. 25 | 26 | ### Project structure 27 | 28 | ``` 29 | agent-starter-react/ 30 | ├── app/ 31 | │ ├── (app)/ 32 | │ ├── api/ 33 | │ ├── components/ 34 | │ ├── fonts/ 35 | │ ├── globals.css 36 | │ └── layout.tsx 37 | ├── components/ 38 | │ ├── livekit/ 39 | │ ├── ui/ 40 | │ ├── app.tsx 41 | │ ├── session-view.tsx 42 | │ └── welcome.tsx 43 | ├── hooks/ 44 | ├── lib/ 45 | ├── public/ 46 | └── package.json 47 | ``` 48 | 49 | ## Getting started 50 | 51 | > [!TIP] 52 | > If you'd like to try this application without modification, you can deploy an instance in just a few clicks with [LiveKit Cloud Sandbox](https://cloud.livekit.io/projects/p_/sandbox/templates/agent-starter-react). 53 | 54 | [![Open on LiveKit](https://img.shields.io/badge/Open%20on%20LiveKit%20Cloud-002CF2?style=for-the-badge&logo=external-link)](https://cloud.livekit.io/projects/p_/sandbox/templates/agent-starter-react) 55 | 56 | Run the following command to automatically clone this template. 57 | 58 | ```bash 59 | lk app create --template agent-starter-react 60 | ``` 61 | 62 | Then run the app with: 63 | 64 | ```bash 65 | pnpm install 66 | pnpm dev 67 | ``` 68 | 69 | And open http://localhost:3000 in your browser. 70 | 71 | You'll also need an agent to speak with. Try our starter agent for [Python](https://github.com/livekit-examples/agent-starter-python), [Node.js](https://github.com/livekit-examples/agent-starter-node), or [create your own from scratch](https://docs.livekit.io/agents/start/voice-ai/). 72 | 73 | ## Configuration 74 | 75 | This starter is designed to be flexible so you can adapt it to your specific agent use case. You can easily configure it to work with different types of inputs and outputs: 76 | 77 | #### Example: App configuration (`app-config.ts`) 78 | 79 | ```ts 80 | export const APP_CONFIG_DEFAULTS: AppConfig = { 81 | companyName: 'LiveKit', 82 | pageTitle: 'LiveKit Voice Agent', 83 | pageDescription: 'A voice agent built with LiveKit', 84 | 85 | supportsChatInput: true, 86 | supportsVideoInput: true, 87 | supportsScreenShare: true, 88 | isPreConnectBufferEnabled: true, 89 | 90 | logo: '/lk-logo.svg', 91 | accent: '#002cf2', 92 | logoDark: '/lk-logo-dark.svg', 93 | accentDark: '#1fd5f9', 94 | startButtonText: 'Start call', 95 | 96 | // for LiveKit Cloud Sandbox 97 | sandboxId: undefined, 98 | agentName: undefined, 99 | }; 100 | ``` 101 | 102 | You can update these values in [`app-config.ts`](./app-config.ts) to customize branding, features, and UI text for your deployment. 103 | 104 | > [!NOTE] 105 | > The `sandboxId` and `agentName` are for the LiveKit Cloud Sandbox environment. 106 | > They are not used for local development. 107 | 108 | #### Environment Variables 109 | 110 | You'll also need to configure your LiveKit credentials in `.env.local` (copy `.env.example` if you don't have one): 111 | 112 | ```env 113 | LIVEKIT_API_KEY=your_livekit_api_key 114 | LIVEKIT_API_SECRET=your_livekit_api_secret 115 | LIVEKIT_URL=https://your-livekit-server-url 116 | ``` 117 | 118 | These are required for the voice agent functionality to work with your LiveKit project. 119 | 120 | ## Contributing 121 | 122 | This template is open source and we welcome contributions! Please open a PR or issue through GitHub, and don't forget to join us in the [LiveKit Community Slack](https://livekit.io/join-slack)! 123 | -------------------------------------------------------------------------------- /TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | A Next.js frontend for a simple AI voice assistant using LiveKit's official [JavaScript SDK](https://github.com/livekit/client-sdk-js) and [React Components](https://github.com/livekit/components-js). The application implements its own token server, and supports [voice](https://docs.livekit.io/agents/start/voice-ai/), [transcription](https://docs.livekit.io/agents/build/text/), and [virtual avatars](https://docs.livekit.io/agents/integrations/avatar/). 4 | 5 | ## Sandbox 6 | 7 | When deployed in a sandbox, LiveKit will host an instance of this application for you, providing a unique, shareable URL through which you can access it. Any agents running with the same LiveKit project credentials will join, meaning that you can rapidly iterate on your agent prototypes, and share the results instantly with friends and colleagues. To begin testing your agent, deploy this app in sandbox then set up an agent on your local machine using the [Voice AI Quickstart](https://docs.livekit.io/start/voice-ai): 8 | 9 | ## Installation 10 | 11 | To run this application locally, clone the repo or use the [LiveKit CLI](https://docs.livekit.io/home/cli/cli-setup/): 12 | 13 | ```console 14 | lk app create --template agent-starter-react 15 | ``` 16 | 17 | For more information on prototyping using LiveKit Sandbox, see the [documentation](https://docs.livekit.io/home/cloud/sandbox/). 18 | -------------------------------------------------------------------------------- /app-config.ts: -------------------------------------------------------------------------------- 1 | export interface AppConfig { 2 | pageTitle: string; 3 | pageDescription: string; 4 | companyName: string; 5 | 6 | supportsChatInput: boolean; 7 | supportsVideoInput: boolean; 8 | supportsScreenShare: boolean; 9 | isPreConnectBufferEnabled: boolean; 10 | 11 | logo: string; 12 | startButtonText: string; 13 | accent?: string; 14 | logoDark?: string; 15 | accentDark?: string; 16 | 17 | // for LiveKit Cloud Sandbox 18 | sandboxId?: string; 19 | agentName?: string; 20 | } 21 | 22 | export const APP_CONFIG_DEFAULTS: AppConfig = { 23 | companyName: 'LiveKit', 24 | pageTitle: 'LiveKit Voice Agent', 25 | pageDescription: 'A voice agent built with LiveKit', 26 | 27 | supportsChatInput: true, 28 | supportsVideoInput: true, 29 | supportsScreenShare: true, 30 | isPreConnectBufferEnabled: true, 31 | 32 | logo: '/lk-logo.svg', 33 | accent: '#002cf2', 34 | logoDark: '/lk-logo-dark.svg', 35 | accentDark: '#1fd5f9', 36 | startButtonText: 'Start call', 37 | 38 | // for LiveKit Cloud Sandbox 39 | sandboxId: undefined, 40 | agentName: undefined, 41 | }; 42 | -------------------------------------------------------------------------------- /app/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | import { getAppConfig } from '@/lib/utils'; 3 | 4 | interface LayoutProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export default async function Layout({ children }: LayoutProps) { 9 | const hdrs = await headers(); 10 | const { companyName, logo, logoDark } = await getAppConfig(hdrs); 11 | 12 | return ( 13 | <> 14 |
15 | 21 | {/* eslint-disable-next-line @next/next/no-img-element */} 22 | {`${companyName} 23 | {/* eslint-disable-next-line @next/next/no-img-element */} 24 | {`${companyName} 29 | 30 | 31 | Built with{' '} 32 | 38 | LiveKit Agents 39 | 40 | 41 |
42 | 43 | {children} 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/(app)/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | import { ImageResponse } from 'next/og'; 3 | import getImageSize from 'buffer-image-size'; 4 | import mime from 'mime'; 5 | import { existsSync } from 'node:fs'; 6 | import { readFile } from 'node:fs/promises'; 7 | import { join } from 'node:path'; 8 | import { APP_CONFIG_DEFAULTS } from '@/app-config'; 9 | import { getAppConfig } from '@/lib/utils'; 10 | 11 | type Dimensions = { 12 | width: number; 13 | height: number; 14 | }; 15 | 16 | type ImageData = { 17 | base64: string; 18 | dimensions: Dimensions; 19 | }; 20 | 21 | // Image metadata 22 | export const alt = 'About Acme'; 23 | export const size = { 24 | width: 1200, 25 | height: 628, 26 | }; 27 | 28 | function isRemoteFile(uri: string) { 29 | return uri.startsWith('http'); 30 | } 31 | 32 | function doesLocalFileExist(uri: string) { 33 | return existsSync(join(process.cwd(), uri)); 34 | } 35 | 36 | // LOCAL FILES MUST BE IN PUBLIC FOLDER 37 | async function loadFileData(filePath: string): Promise { 38 | if (isRemoteFile(filePath)) { 39 | const response = await fetch(filePath); 40 | if (!response.ok) { 41 | throw new Error(`Failed to fetch ${filePath} - ${response.status} ${response.statusText}`); 42 | } 43 | return await response.arrayBuffer(); 44 | } 45 | 46 | // Try file system first (works in local development) 47 | if (doesLocalFileExist(filePath)) { 48 | const buffer = await readFile(join(process.cwd(), filePath)); 49 | return buffer.buffer.slice( 50 | buffer.byteOffset, 51 | buffer.byteOffset + buffer.byteLength 52 | ) as ArrayBuffer; 53 | } 54 | 55 | // Fallback to fetching from public URL (works in production) 56 | const publicFilePath = filePath.replace('public/', ''); 57 | const fontUrl = `https://${process.env.VERCEL_URL}/${publicFilePath}`; 58 | 59 | const response = await fetch(fontUrl); 60 | if (!response.ok) { 61 | throw new Error(`Failed to fetch ${fontUrl} - ${response.status} ${response.statusText}`); 62 | } 63 | 64 | return await response.arrayBuffer(); 65 | } 66 | 67 | async function getImageData(uri: string, fallbackUri?: string): Promise { 68 | try { 69 | const fileData = await loadFileData(uri); 70 | const buffer = Buffer.from(fileData); 71 | const mimeType = mime.getType(uri); 72 | 73 | return { 74 | base64: `data:${mimeType};base64,${buffer.toString('base64')}`, 75 | dimensions: getImageSize(buffer), 76 | }; 77 | } catch (e) { 78 | if (fallbackUri) { 79 | return getImageData(fallbackUri, fallbackUri); 80 | } 81 | throw e; 82 | } 83 | } 84 | 85 | function scaleImageSize(size: { width: number; height: number }, desiredHeight: number) { 86 | const scale = desiredHeight / size.height; 87 | return { 88 | width: size.width * scale, 89 | height: desiredHeight, 90 | }; 91 | } 92 | 93 | function cleanPageTitle(appName: string) { 94 | if (appName === APP_CONFIG_DEFAULTS.pageTitle) { 95 | return 'Voice agent'; 96 | } 97 | 98 | return appName; 99 | } 100 | 101 | export const contentType = 'image/png'; 102 | 103 | // Image generation 104 | export default async function Image() { 105 | const hdrs = await headers(); 106 | const appConfig = await getAppConfig(hdrs); 107 | 108 | const pageTitle = cleanPageTitle(appConfig.pageTitle); 109 | const logoUri = appConfig.logoDark || appConfig.logo; 110 | const isLogoUriLocal = logoUri.includes('lk-logo'); 111 | const wordmarkUri = logoUri === APP_CONFIG_DEFAULTS.logoDark ? 'public/lk-wordmark.svg' : logoUri; 112 | 113 | // Load fonts - use file system in dev, fetch in production 114 | let commitMonoData: ArrayBuffer | undefined; 115 | let everettLightData: ArrayBuffer | undefined; 116 | 117 | try { 118 | commitMonoData = await loadFileData('public/commit-mono-400-regular.woff'); 119 | everettLightData = await loadFileData('public/everett-light.woff'); 120 | } catch (e) { 121 | console.error('Failed to load fonts:', e); 122 | // Continue without custom fonts - will fall back to system fonts 123 | } 124 | 125 | // bg 126 | const { base64: bgSrcBase64 } = await getImageData('public/opengraph-image-bg.png'); 127 | 128 | // wordmark 129 | const { base64: wordmarkSrcBase64, dimensions: wordmarkDimensions } = isLogoUriLocal 130 | ? await getImageData(wordmarkUri) 131 | : await getImageData(logoUri); 132 | const wordmarkSize = scaleImageSize(wordmarkDimensions, isLogoUriLocal ? 32 : 64); 133 | 134 | // logo 135 | const { base64: logoSrcBase64, dimensions: logoDimensions } = await getImageData( 136 | logoUri, 137 | 'public/lk-logo-dark.svg' 138 | ); 139 | const logoSize = scaleImageSize(logoDimensions, 24); 140 | 141 | return new ImageResponse( 142 | ( 143 | // ImageResponse JSX element 144 |
157 | {/* wordmark */} 158 |
168 | {/* eslint-disable-next-line jsx-a11y/alt-text */} 169 | 170 |
171 | {/* logo */} 172 |
182 | {/* eslint-disable-next-line jsx-a11y/alt-text */} 183 | 184 |
185 | {/* title */} 186 |
197 |
210 | SANDBOX 211 |
212 |
221 | {pageTitle} 222 |
223 |
224 |
225 | ), 226 | // ImageResponse options 227 | { 228 | // For convenience, we can re-use the exported opengraph-image 229 | // size config to also set the ImageResponse's width and height. 230 | ...size, 231 | fonts: [ 232 | ...(commitMonoData 233 | ? [ 234 | { 235 | name: 'CommitMono', 236 | data: commitMonoData, 237 | style: 'normal' as const, 238 | weight: 400 as const, 239 | }, 240 | ] 241 | : []), 242 | ...(everettLightData 243 | ? [ 244 | { 245 | name: 'Everett', 246 | data: everettLightData, 247 | style: 'normal' as const, 248 | weight: 300 as const, 249 | }, 250 | ] 251 | : []), 252 | ], 253 | } 254 | ); 255 | } 256 | -------------------------------------------------------------------------------- /app/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | import { App } from '@/components/app/app'; 3 | import { getAppConfig } from '@/lib/utils'; 4 | 5 | export default async function Page() { 6 | const hdrs = await headers(); 7 | const appConfig = await getAppConfig(hdrs); 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/api/connection-details/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk'; 3 | import { RoomConfiguration } from '@livekit/protocol'; 4 | 5 | type ConnectionDetails = { 6 | serverUrl: string; 7 | roomName: string; 8 | participantName: string; 9 | participantToken: string; 10 | }; 11 | 12 | // NOTE: you are expected to define the following environment variables in `.env.local`: 13 | const API_KEY = process.env.LIVEKIT_API_KEY; 14 | const API_SECRET = process.env.LIVEKIT_API_SECRET; 15 | const LIVEKIT_URL = process.env.LIVEKIT_URL; 16 | 17 | // don't cache the results 18 | export const revalidate = 0; 19 | 20 | export async function POST(req: Request) { 21 | try { 22 | if (LIVEKIT_URL === undefined) { 23 | throw new Error('LIVEKIT_URL is not defined'); 24 | } 25 | if (API_KEY === undefined) { 26 | throw new Error('LIVEKIT_API_KEY is not defined'); 27 | } 28 | if (API_SECRET === undefined) { 29 | throw new Error('LIVEKIT_API_SECRET is not defined'); 30 | } 31 | 32 | // Parse agent configuration from request body 33 | const body = await req.json(); 34 | const agentName: string = body?.room_config?.agents?.[0]?.agent_name; 35 | 36 | // Generate participant token 37 | const participantName = 'user'; 38 | const participantIdentity = `voice_assistant_user_${Math.floor(Math.random() * 10_000)}`; 39 | const roomName = `voice_assistant_room_${Math.floor(Math.random() * 10_000)}`; 40 | 41 | const participantToken = await createParticipantToken( 42 | { identity: participantIdentity, name: participantName }, 43 | roomName, 44 | agentName 45 | ); 46 | 47 | // Return connection details 48 | const data: ConnectionDetails = { 49 | serverUrl: LIVEKIT_URL, 50 | roomName, 51 | participantToken: participantToken, 52 | participantName, 53 | }; 54 | const headers = new Headers({ 55 | 'Cache-Control': 'no-store', 56 | }); 57 | return NextResponse.json(data, { headers }); 58 | } catch (error) { 59 | if (error instanceof Error) { 60 | console.error(error); 61 | return new NextResponse(error.message, { status: 500 }); 62 | } 63 | } 64 | } 65 | 66 | function createParticipantToken( 67 | userInfo: AccessTokenOptions, 68 | roomName: string, 69 | agentName?: string 70 | ): Promise { 71 | const at = new AccessToken(API_KEY, API_SECRET, { 72 | ...userInfo, 73 | ttl: '15m', 74 | }); 75 | const grant: VideoGrant = { 76 | room: roomName, 77 | roomJoin: true, 78 | canPublish: true, 79 | canPublishData: true, 80 | canSubscribe: true, 81 | }; 82 | at.addGrant(grant); 83 | 84 | if (agentName) { 85 | at.roomConfig = new RoomConfiguration({ 86 | agents: [{ agentName }], 87 | }); 88 | } 89 | 90 | return at.toJwt(); 91 | } 92 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Public_Sans } from 'next/font/google'; 2 | import localFont from 'next/font/local'; 3 | import { headers } from 'next/headers'; 4 | import { ThemeProvider } from '@/components/app/theme-provider'; 5 | import { ThemeToggle } from '@/components/app/theme-toggle'; 6 | import { cn, getAppConfig, getStyles } from '@/lib/utils'; 7 | import '@/styles/globals.css'; 8 | 9 | const publicSans = Public_Sans({ 10 | variable: '--font-public-sans', 11 | subsets: ['latin'], 12 | }); 13 | 14 | const commitMono = localFont({ 15 | display: 'swap', 16 | variable: '--font-commit-mono', 17 | src: [ 18 | { 19 | path: '../fonts/CommitMono-400-Regular.otf', 20 | weight: '400', 21 | style: 'normal', 22 | }, 23 | { 24 | path: '../fonts/CommitMono-700-Regular.otf', 25 | weight: '700', 26 | style: 'normal', 27 | }, 28 | { 29 | path: '../fonts/CommitMono-400-Italic.otf', 30 | weight: '400', 31 | style: 'italic', 32 | }, 33 | { 34 | path: '../fonts/CommitMono-700-Italic.otf', 35 | weight: '700', 36 | style: 'italic', 37 | }, 38 | ], 39 | }); 40 | 41 | interface RootLayoutProps { 42 | children: React.ReactNode; 43 | } 44 | 45 | export default async function RootLayout({ children }: RootLayoutProps) { 46 | const hdrs = await headers(); 47 | const appConfig = await getAppConfig(hdrs); 48 | const { pageTitle, pageDescription } = appConfig; 49 | const styles = getStyles(appConfig); 50 | 51 | return ( 52 | 61 | 62 | {styles && } 63 | {pageTitle} 64 | 65 | 66 | 67 | 73 | {children} 74 |
75 | 76 |
77 |
78 | 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "phosphor" 21 | } 22 | -------------------------------------------------------------------------------- /components/app/app.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useMemo } from 'react'; 4 | import { TokenSource } from 'livekit-client'; 5 | import { 6 | RoomAudioRenderer, 7 | SessionProvider, 8 | StartAudio, 9 | useSession, 10 | } from '@livekit/components-react'; 11 | import type { AppConfig } from '@/app-config'; 12 | import { ViewController } from '@/components/app/view-controller'; 13 | import { Toaster } from '@/components/livekit/toaster'; 14 | import { useAgentErrors } from '@/hooks/useAgentErrors'; 15 | import { useDebugMode } from '@/hooks/useDebug'; 16 | import { getSandboxTokenSource } from '@/lib/utils'; 17 | 18 | const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production'; 19 | 20 | function AppSetup() { 21 | useDebugMode({ enabled: IN_DEVELOPMENT }); 22 | useAgentErrors(); 23 | 24 | return null; 25 | } 26 | 27 | interface AppProps { 28 | appConfig: AppConfig; 29 | } 30 | 31 | export function App({ appConfig }: AppProps) { 32 | const tokenSource = useMemo(() => { 33 | return typeof process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT === 'string' 34 | ? getSandboxTokenSource(appConfig) 35 | : TokenSource.endpoint('/api/connection-details'); 36 | }, [appConfig]); 37 | 38 | const session = useSession( 39 | tokenSource, 40 | appConfig.agentName ? { agentName: appConfig.agentName } : undefined 41 | ); 42 | 43 | return ( 44 | 45 | 46 |
47 | 48 |
49 | 50 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/app/chat-transcript.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react'; 4 | import { type ReceivedMessage } from '@livekit/components-react'; 5 | import { ChatEntry } from '@/components/livekit/chat-entry'; 6 | 7 | const MotionContainer = motion.create('div'); 8 | const MotionChatEntry = motion.create(ChatEntry); 9 | 10 | const CONTAINER_MOTION_PROPS = { 11 | variants: { 12 | hidden: { 13 | opacity: 0, 14 | transition: { 15 | ease: 'easeOut', 16 | duration: 0.3, 17 | staggerChildren: 0.1, 18 | staggerDirection: -1, 19 | }, 20 | }, 21 | visible: { 22 | opacity: 1, 23 | transition: { 24 | delay: 0.2, 25 | ease: 'easeOut', 26 | duration: 0.3, 27 | stagerDelay: 0.2, 28 | staggerChildren: 0.1, 29 | staggerDirection: 1, 30 | }, 31 | }, 32 | }, 33 | initial: 'hidden', 34 | animate: 'visible', 35 | exit: 'hidden', 36 | }; 37 | 38 | const MESSAGE_MOTION_PROPS = { 39 | variants: { 40 | hidden: { 41 | opacity: 0, 42 | translateY: 10, 43 | }, 44 | visible: { 45 | opacity: 1, 46 | translateY: 0, 47 | }, 48 | }, 49 | }; 50 | 51 | interface ChatTranscriptProps { 52 | hidden?: boolean; 53 | messages?: ReceivedMessage[]; 54 | } 55 | 56 | export function ChatTranscript({ 57 | hidden = false, 58 | messages = [], 59 | ...props 60 | }: ChatTranscriptProps & Omit, 'ref'>) { 61 | return ( 62 | 63 | {!hidden && ( 64 | 65 | {messages.map((receivedMessage) => { 66 | const { id, timestamp, from, message } = receivedMessage; 67 | const locale = navigator?.language ?? 'en-US'; 68 | const messageOrigin = from?.isLocal ? 'local' : 'remote'; 69 | const hasBeenEdited = 70 | receivedMessage.type === 'chatMessage' && !!receivedMessage.editTimestamp; 71 | 72 | return ( 73 | 82 | ); 83 | })} 84 | 85 | )} 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /components/app/preconnect-message.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, motion } from 'motion/react'; 4 | import { type ReceivedMessage } from '@livekit/components-react'; 5 | import { ShimmerText } from '@/components/livekit/shimmer-text'; 6 | import { cn } from '@/lib/utils'; 7 | 8 | const MotionMessage = motion.create('p'); 9 | 10 | const VIEW_MOTION_PROPS = { 11 | variants: { 12 | visible: { 13 | opacity: 1, 14 | transition: { 15 | ease: 'easeIn', 16 | duration: 0.5, 17 | delay: 0.8, 18 | }, 19 | }, 20 | hidden: { 21 | opacity: 0, 22 | transition: { 23 | ease: 'easeIn', 24 | duration: 0.5, 25 | delay: 0, 26 | }, 27 | }, 28 | }, 29 | initial: 'hidden', 30 | animate: 'visible', 31 | exit: 'hidden', 32 | }; 33 | 34 | interface PreConnectMessageProps { 35 | messages?: ReceivedMessage[]; 36 | className?: string; 37 | } 38 | 39 | export function PreConnectMessage({ className, messages = [] }: PreConnectMessageProps) { 40 | return ( 41 | 42 | {messages.length === 0 && ( 43 | 0} 46 | className={cn('pointer-events-none text-center', className)} 47 | > 48 | 49 | Agent is listening, ask it a question 50 | 51 | 52 | )} 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/app/session-view.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useRef, useState } from 'react'; 4 | import { motion } from 'motion/react'; 5 | import { useSessionContext, useSessionMessages } from '@livekit/components-react'; 6 | import type { AppConfig } from '@/app-config'; 7 | import { ChatTranscript } from '@/components/app/chat-transcript'; 8 | import { PreConnectMessage } from '@/components/app/preconnect-message'; 9 | import { TileLayout } from '@/components/app/tile-layout'; 10 | import { 11 | AgentControlBar, 12 | type ControlBarControls, 13 | } from '@/components/livekit/agent-control-bar/agent-control-bar'; 14 | import { cn } from '@/lib/utils'; 15 | import { ScrollArea } from '../livekit/scroll-area/scroll-area'; 16 | 17 | const MotionBottom = motion.create('div'); 18 | 19 | const BOTTOM_VIEW_MOTION_PROPS = { 20 | variants: { 21 | visible: { 22 | opacity: 1, 23 | translateY: '0%', 24 | }, 25 | hidden: { 26 | opacity: 0, 27 | translateY: '100%', 28 | }, 29 | }, 30 | initial: 'hidden', 31 | animate: 'visible', 32 | exit: 'hidden', 33 | transition: { 34 | duration: 0.3, 35 | delay: 0.5, 36 | ease: 'easeOut', 37 | }, 38 | }; 39 | 40 | interface FadeProps { 41 | top?: boolean; 42 | bottom?: boolean; 43 | className?: string; 44 | } 45 | 46 | export function Fade({ top = false, bottom = false, className }: FadeProps) { 47 | return ( 48 |
56 | ); 57 | } 58 | 59 | interface SessionViewProps { 60 | appConfig: AppConfig; 61 | } 62 | 63 | export const SessionView = ({ 64 | appConfig, 65 | ...props 66 | }: React.ComponentProps<'section'> & SessionViewProps) => { 67 | const session = useSessionContext(); 68 | const { messages } = useSessionMessages(session); 69 | const [chatOpen, setChatOpen] = useState(false); 70 | const scrollAreaRef = useRef(null); 71 | 72 | const controls: ControlBarControls = { 73 | leave: true, 74 | microphone: true, 75 | chat: appConfig.supportsChatInput, 76 | camera: appConfig.supportsVideoInput, 77 | screenShare: appConfig.supportsVideoInput, 78 | }; 79 | 80 | useEffect(() => { 81 | const lastMessage = messages.at(-1); 82 | const lastMessageIsLocal = lastMessage?.from?.isLocal === true; 83 | 84 | if (scrollAreaRef.current && lastMessageIsLocal) { 85 | scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; 86 | } 87 | }, [messages]); 88 | 89 | return ( 90 |
91 | {/* Chat Transcript */} 92 |
98 | 99 | 100 | 106 |
107 | 108 | {/* Tile Layout */} 109 | 110 | 111 | {/* Bottom */} 112 | 116 | {appConfig.isPreConnectBufferEnabled && ( 117 | 118 | )} 119 |
120 | 121 | 127 |
128 |
129 |
130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /components/app/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes'; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /components/app/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTheme } from 'next-themes'; 4 | import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | interface ThemeToggleProps { 8 | className?: string; 9 | } 10 | 11 | export function ThemeToggle({ className }: ThemeToggleProps) { 12 | const { theme, setTheme } = useTheme(); 13 | 14 | return ( 15 |
21 | Color scheme toggle 22 | 31 | 44 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /components/app/tile-layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Track } from 'livekit-client'; 3 | import { AnimatePresence, motion } from 'motion/react'; 4 | import { 5 | BarVisualizer, 6 | type TrackReference, 7 | VideoTrack, 8 | useLocalParticipant, 9 | useTracks, 10 | useVoiceAssistant, 11 | } from '@livekit/components-react'; 12 | import { cn } from '@/lib/utils'; 13 | 14 | const MotionContainer = motion.create('div'); 15 | 16 | const ANIMATION_TRANSITION = { 17 | type: 'spring', 18 | stiffness: 675, 19 | damping: 75, 20 | mass: 1, 21 | }; 22 | 23 | const classNames = { 24 | // GRID 25 | // 2 Columns x 3 Rows 26 | grid: [ 27 | 'h-full w-full', 28 | 'grid gap-x-2 place-content-center', 29 | 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]', 30 | ], 31 | // Agent 32 | // chatOpen: true, 33 | // hasSecondTile: true 34 | // layout: Column 1 / Row 1 35 | // align: x-end y-center 36 | agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'], 37 | // Agent 38 | // chatOpen: true, 39 | // hasSecondTile: false 40 | // layout: Column 1 / Row 1 / Column-Span 2 41 | // align: x-center y-center 42 | agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'], 43 | // Agent 44 | // chatOpen: false 45 | // layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3 46 | // align: x-center y-center 47 | agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'], 48 | // Second tile 49 | // chatOpen: true, 50 | // hasSecondTile: true 51 | // layout: Column 2 / Row 1 52 | // align: x-start y-center 53 | secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'], 54 | // Second tile 55 | // chatOpen: false, 56 | // hasSecondTile: false 57 | // layout: Column 2 / Row 2 58 | // align: x-end y-end 59 | secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'], 60 | }; 61 | 62 | export function useLocalTrackRef(source: Track.Source) { 63 | const { localParticipant } = useLocalParticipant(); 64 | const publication = localParticipant.getTrackPublication(source); 65 | const trackRef = useMemo( 66 | () => (publication ? { source, participant: localParticipant, publication } : undefined), 67 | [source, publication, localParticipant] 68 | ); 69 | return trackRef; 70 | } 71 | 72 | interface TileLayoutProps { 73 | chatOpen: boolean; 74 | } 75 | 76 | export function TileLayout({ chatOpen }: TileLayoutProps) { 77 | const { 78 | state: agentState, 79 | audioTrack: agentAudioTrack, 80 | videoTrack: agentVideoTrack, 81 | } = useVoiceAssistant(); 82 | const [screenShareTrack] = useTracks([Track.Source.ScreenShare]); 83 | const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera); 84 | 85 | const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted; 86 | const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted; 87 | const hasSecondTile = isCameraEnabled || isScreenShareEnabled; 88 | 89 | const animationDelay = chatOpen ? 0 : 0.15; 90 | const isAvatar = agentVideoTrack !== undefined; 91 | const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0; 92 | const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0; 93 | 94 | return ( 95 |
96 |
97 |
98 | {/* Agent */} 99 |
107 | 108 | {!isAvatar && ( 109 | // Audio Agent 110 | 130 | 137 | 144 | 145 | 146 | )} 147 | 148 | {isAvatar && ( 149 | // Avatar Agent 150 | 181 | 187 | 188 | )} 189 | 190 |
191 | 192 |
199 | {/* Camera & Screen Share */} 200 | 201 | {((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && ( 202 | 224 | 230 | 231 | )} 232 | 233 |
234 |
235 |
236 |
237 | ); 238 | } 239 | -------------------------------------------------------------------------------- /components/app/view-controller.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { AnimatePresence, motion } from 'motion/react'; 4 | import { useSessionContext } from '@livekit/components-react'; 5 | import type { AppConfig } from '@/app-config'; 6 | import { SessionView } from '@/components/app/session-view'; 7 | import { WelcomeView } from '@/components/app/welcome-view'; 8 | 9 | const MotionWelcomeView = motion.create(WelcomeView); 10 | const MotionSessionView = motion.create(SessionView); 11 | 12 | const VIEW_MOTION_PROPS = { 13 | variants: { 14 | visible: { 15 | opacity: 1, 16 | }, 17 | hidden: { 18 | opacity: 0, 19 | }, 20 | }, 21 | initial: 'hidden', 22 | animate: 'visible', 23 | exit: 'hidden', 24 | transition: { 25 | duration: 0.5, 26 | ease: 'linear', 27 | }, 28 | }; 29 | 30 | interface ViewControllerProps { 31 | appConfig: AppConfig; 32 | } 33 | 34 | export function ViewController({ appConfig }: ViewControllerProps) { 35 | const { isConnected, start } = useSessionContext(); 36 | 37 | return ( 38 | 39 | {/* Welcome view */} 40 | {!isConnected && ( 41 | 47 | )} 48 | {/* Session view */} 49 | {isConnected && ( 50 | 51 | )} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/app/welcome-view.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/livekit/button'; 2 | 3 | function WelcomeImage() { 4 | return ( 5 | 13 | 17 | 18 | ); 19 | } 20 | 21 | interface WelcomeViewProps { 22 | startButtonText: string; 23 | onStartCall: () => void; 24 | } 25 | 26 | export const WelcomeView = ({ 27 | startButtonText, 28 | onStartCall, 29 | ref, 30 | }: React.ComponentProps<'div'> & WelcomeViewProps) => { 31 | return ( 32 |
33 |
34 | 35 | 36 |

37 | Chat live with your voice AI agent 38 |

39 | 40 | 43 |
44 | 45 |
46 |

47 | Need help getting set up? Check out the{' '} 48 | 54 | Voice AI quickstart 55 | 56 | . 57 |

58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/agent-control-bar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { type HTMLAttributes, useCallback, useState } from 'react'; 4 | import { Track } from 'livekit-client'; 5 | import { useChat, useRemoteParticipants } from '@livekit/components-react'; 6 | import { ChatTextIcon, PhoneDisconnectIcon } from '@phosphor-icons/react/dist/ssr'; 7 | import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; 8 | import { Button } from '@/components/livekit/button'; 9 | import { Toggle } from '@/components/livekit/toggle'; 10 | import { cn } from '@/lib/utils'; 11 | import { ChatInput } from './chat-input'; 12 | import { UseInputControlsProps, useInputControls } from './hooks/use-input-controls'; 13 | import { usePublishPermissions } from './hooks/use-publish-permissions'; 14 | import { TrackSelector } from './track-selector'; 15 | 16 | export interface ControlBarControls { 17 | leave?: boolean; 18 | camera?: boolean; 19 | microphone?: boolean; 20 | screenShare?: boolean; 21 | chat?: boolean; 22 | } 23 | 24 | export interface AgentControlBarProps extends UseInputControlsProps { 25 | controls?: ControlBarControls; 26 | isConnected?: boolean; 27 | onChatOpenChange?: (open: boolean) => void; 28 | onDeviceError?: (error: { source: Track.Source; error: Error }) => void; 29 | } 30 | 31 | /** 32 | * A control bar specifically designed for voice assistant interfaces 33 | */ 34 | export function AgentControlBar({ 35 | controls, 36 | saveUserChoices = true, 37 | className, 38 | isConnected = false, 39 | onDisconnect, 40 | onDeviceError, 41 | onChatOpenChange, 42 | ...props 43 | }: AgentControlBarProps & HTMLAttributes) { 44 | const { send } = useChat(); 45 | const participants = useRemoteParticipants(); 46 | const [chatOpen, setChatOpen] = useState(false); 47 | const publishPermissions = usePublishPermissions(); 48 | const { 49 | micTrackRef, 50 | cameraToggle, 51 | microphoneToggle, 52 | screenShareToggle, 53 | handleAudioDeviceChange, 54 | handleVideoDeviceChange, 55 | handleMicrophoneDeviceSelectError, 56 | handleCameraDeviceSelectError, 57 | } = useInputControls({ onDeviceError, saveUserChoices }); 58 | 59 | const handleSendMessage = async (message: string) => { 60 | await send(message); 61 | }; 62 | 63 | const handleToggleTranscript = useCallback( 64 | (open: boolean) => { 65 | setChatOpen(open); 66 | onChatOpenChange?.(open); 67 | }, 68 | [onChatOpenChange, setChatOpen] 69 | ); 70 | 71 | const visibleControls = { 72 | leave: controls?.leave ?? true, 73 | microphone: controls?.microphone ?? publishPermissions.microphone, 74 | screenShare: controls?.screenShare ?? publishPermissions.screenShare, 75 | camera: controls?.camera ?? publishPermissions.camera, 76 | chat: controls?.chat ?? publishPermissions.data, 77 | }; 78 | 79 | const isAgentAvailable = participants.some((p) => p.isAgent); 80 | 81 | return ( 82 |
90 | {/* Chat Input */} 91 | {visibleControls.chat && ( 92 | 97 | )} 98 | 99 |
100 |
101 | {/* Toggle Microphone */} 102 | {visibleControls.microphone && ( 103 | 114 | )} 115 | 116 | {/* Toggle Camera */} 117 | {visibleControls.camera && ( 118 | 129 | )} 130 | 131 | {/* Toggle Screen Share */} 132 | {visibleControls.screenShare && ( 133 | 142 | )} 143 | 144 | {/* Toggle Transcript */} 145 | 152 | 153 | 154 |
155 | 156 | {/* Disconnect */} 157 | {visibleControls.leave && ( 158 | 168 | )} 169 |
170 |
171 | ); 172 | } 173 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/chat-input.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { motion } from 'motion/react'; 3 | import { PaperPlaneRightIcon, SpinnerIcon } from '@phosphor-icons/react/dist/ssr'; 4 | import { Button } from '@/components/livekit/button'; 5 | 6 | const MOTION_PROPS = { 7 | variants: { 8 | hidden: { 9 | height: 0, 10 | opacity: 0, 11 | marginBottom: 0, 12 | }, 13 | visible: { 14 | height: 'auto', 15 | opacity: 1, 16 | marginBottom: 12, 17 | }, 18 | }, 19 | initial: 'hidden', 20 | transition: { 21 | duration: 0.3, 22 | ease: 'easeOut', 23 | }, 24 | }; 25 | 26 | interface ChatInputProps { 27 | chatOpen: boolean; 28 | isAgentAvailable?: boolean; 29 | onSend?: (message: string) => void; 30 | } 31 | 32 | export function ChatInput({ 33 | chatOpen, 34 | isAgentAvailable = false, 35 | onSend = async () => {}, 36 | }: ChatInputProps) { 37 | const inputRef = useRef(null); 38 | const [isSending, setIsSending] = useState(false); 39 | const [message, setMessage] = useState(''); 40 | 41 | const handleSubmit = async (e: React.FormEvent) => { 42 | e.preventDefault(); 43 | 44 | try { 45 | setIsSending(true); 46 | await onSend(message); 47 | setMessage(''); 48 | } catch (error) { 49 | console.error(error); 50 | } finally { 51 | setIsSending(false); 52 | } 53 | }; 54 | 55 | const isDisabled = isSending || !isAgentAvailable || message.trim().length === 0; 56 | 57 | useEffect(() => { 58 | if (chatOpen && isAgentAvailable) return; 59 | // when not disabled refocus on input 60 | inputRef.current?.focus(); 61 | }, [chatOpen, isAgentAvailable]); 62 | 63 | return ( 64 | 70 |
74 | setMessage(e.target.value)} 82 | className="h-8 flex-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" 83 | /> 84 | 98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/hooks/use-input-controls.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { Track } from 'livekit-client'; 3 | import { 4 | type TrackReferenceOrPlaceholder, 5 | useLocalParticipant, 6 | usePersistentUserChoices, 7 | useTrackToggle, 8 | } from '@livekit/components-react'; 9 | 10 | export interface UseInputControlsProps { 11 | saveUserChoices?: boolean; 12 | onDisconnect?: () => void; 13 | onDeviceError?: (error: { source: Track.Source; error: Error }) => void; 14 | } 15 | 16 | export interface UseInputControlsReturn { 17 | micTrackRef: TrackReferenceOrPlaceholder; 18 | microphoneToggle: ReturnType>; 19 | cameraToggle: ReturnType>; 20 | screenShareToggle: ReturnType>; 21 | handleAudioDeviceChange: (deviceId: string) => void; 22 | handleVideoDeviceChange: (deviceId: string) => void; 23 | handleMicrophoneDeviceSelectError: (error: Error) => void; 24 | handleCameraDeviceSelectError: (error: Error) => void; 25 | } 26 | 27 | export function useInputControls({ 28 | saveUserChoices = true, 29 | onDeviceError, 30 | }: UseInputControlsProps = {}): UseInputControlsReturn { 31 | const { microphoneTrack, localParticipant } = useLocalParticipant(); 32 | 33 | const microphoneToggle = useTrackToggle({ 34 | source: Track.Source.Microphone, 35 | onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }), 36 | }); 37 | 38 | const cameraToggle = useTrackToggle({ 39 | source: Track.Source.Camera, 40 | onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }), 41 | }); 42 | 43 | const screenShareToggle = useTrackToggle({ 44 | source: Track.Source.ScreenShare, 45 | onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }), 46 | }); 47 | 48 | const micTrackRef = useMemo(() => { 49 | return { 50 | participant: localParticipant, 51 | source: Track.Source.Microphone, 52 | publication: microphoneTrack, 53 | }; 54 | }, [localParticipant, microphoneTrack]); 55 | 56 | const { 57 | saveAudioInputEnabled, 58 | saveVideoInputEnabled, 59 | saveAudioInputDeviceId, 60 | saveVideoInputDeviceId, 61 | } = usePersistentUserChoices({ preventSave: !saveUserChoices }); 62 | 63 | const handleAudioDeviceChange = useCallback( 64 | (deviceId: string) => { 65 | saveAudioInputDeviceId(deviceId ?? 'default'); 66 | }, 67 | [saveAudioInputDeviceId] 68 | ); 69 | 70 | const handleVideoDeviceChange = useCallback( 71 | (deviceId: string) => { 72 | saveVideoInputDeviceId(deviceId ?? 'default'); 73 | }, 74 | [saveVideoInputDeviceId] 75 | ); 76 | 77 | const handleToggleCamera = useCallback( 78 | async (enabled?: boolean) => { 79 | if (screenShareToggle.enabled) { 80 | screenShareToggle.toggle(false); 81 | } 82 | await cameraToggle.toggle(enabled); 83 | // persist video input enabled preference 84 | saveVideoInputEnabled(!cameraToggle.enabled); 85 | }, 86 | [cameraToggle, screenShareToggle, saveVideoInputEnabled] 87 | ); 88 | 89 | const handleToggleMicrophone = useCallback( 90 | async (enabled?: boolean) => { 91 | await microphoneToggle.toggle(enabled); 92 | // persist audio input enabled preference 93 | saveAudioInputEnabled(!microphoneToggle.enabled); 94 | }, 95 | [microphoneToggle, saveAudioInputEnabled] 96 | ); 97 | 98 | const handleToggleScreenShare = useCallback( 99 | async (enabled?: boolean) => { 100 | if (cameraToggle.enabled) { 101 | cameraToggle.toggle(false); 102 | } 103 | await screenShareToggle.toggle(enabled); 104 | }, 105 | [cameraToggle, screenShareToggle] 106 | ); 107 | const handleMicrophoneDeviceSelectError = useCallback( 108 | (error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }), 109 | [onDeviceError] 110 | ); 111 | 112 | const handleCameraDeviceSelectError = useCallback( 113 | (error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }), 114 | [onDeviceError] 115 | ); 116 | 117 | return { 118 | micTrackRef, 119 | cameraToggle: { 120 | ...cameraToggle, 121 | toggle: handleToggleCamera, 122 | }, 123 | microphoneToggle: { 124 | ...microphoneToggle, 125 | toggle: handleToggleMicrophone, 126 | }, 127 | screenShareToggle: { 128 | ...screenShareToggle, 129 | toggle: handleToggleScreenShare, 130 | }, 131 | handleAudioDeviceChange, 132 | handleVideoDeviceChange, 133 | handleMicrophoneDeviceSelectError, 134 | handleCameraDeviceSelectError, 135 | }; 136 | } 137 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/hooks/use-publish-permissions.ts: -------------------------------------------------------------------------------- 1 | import { Track } from 'livekit-client'; 2 | import { useLocalParticipantPermissions } from '@livekit/components-react'; 3 | 4 | const trackSourceToProtocol = (source: Track.Source) => { 5 | // NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase 6 | switch (source) { 7 | case Track.Source.Camera: 8 | return 1; 9 | case Track.Source.Microphone: 10 | return 2; 11 | case Track.Source.ScreenShare: 12 | return 3; 13 | default: 14 | return 0; 15 | } 16 | }; 17 | 18 | export interface PublishPermissions { 19 | camera: boolean; 20 | microphone: boolean; 21 | screenShare: boolean; 22 | data: boolean; 23 | } 24 | 25 | export function usePublishPermissions(): PublishPermissions { 26 | const localPermissions = useLocalParticipantPermissions(); 27 | 28 | const canPublishSource = (source: Track.Source) => { 29 | return ( 30 | !!localPermissions?.canPublish && 31 | (localPermissions.canPublishSources.length === 0 || 32 | localPermissions.canPublishSources.includes(trackSourceToProtocol(source))) 33 | ); 34 | }; 35 | 36 | return { 37 | camera: canPublishSource(Track.Source.Camera), 38 | microphone: canPublishSource(Track.Source.Microphone), 39 | screenShare: canPublishSource(Track.Source.ScreenShare), 40 | data: localPermissions?.canPublishData ?? false, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/track-device-select.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; 4 | import { cva } from 'class-variance-authority'; 5 | import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'; 6 | import { useMaybeRoomContext, useMediaDeviceSelect } from '@livekit/components-react'; 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from '@/components/livekit/select'; 14 | import { cn } from '@/lib/utils'; 15 | 16 | type DeviceSelectProps = React.ComponentProps & { 17 | kind: MediaDeviceKind; 18 | variant?: 'default' | 'small'; 19 | track?: LocalAudioTrack | LocalVideoTrack | undefined; 20 | requestPermissions?: boolean; 21 | onMediaDeviceError?: (error: Error) => void; 22 | onDeviceListChange?: (devices: MediaDeviceInfo[]) => void; 23 | onActiveDeviceChange?: (deviceId: string) => void; 24 | }; 25 | 26 | const selectVariants = cva( 27 | 'w-full rounded-full px-3 py-2 text-sm cursor-pointer disabled:not-allowed', 28 | { 29 | variants: { 30 | size: { 31 | default: 'w-[180px]', 32 | sm: 'w-auto', 33 | }, 34 | }, 35 | defaultVariants: { 36 | size: 'default', 37 | }, 38 | } 39 | ); 40 | 41 | export function TrackDeviceSelect({ 42 | kind, 43 | track, 44 | size = 'default', 45 | requestPermissions = false, 46 | onMediaDeviceError, 47 | onDeviceListChange, 48 | onActiveDeviceChange, 49 | ...props 50 | }: DeviceSelectProps) { 51 | const room = useMaybeRoomContext(); 52 | const [open, setOpen] = useState(false); 53 | const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions); 54 | const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({ 55 | room, 56 | kind, 57 | track, 58 | requestPermissions: requestPermissionsState, 59 | onError: onMediaDeviceError, 60 | }); 61 | 62 | useEffect(() => { 63 | onDeviceListChange?.(devices); 64 | }, [devices, onDeviceListChange]); 65 | 66 | // When the select opens, ensure that media devices are re-requested in case when they were last 67 | // requested, permissions were not granted 68 | useLayoutEffect(() => { 69 | if (open) { 70 | setRequestPermissionsState(true); 71 | } 72 | }, [open]); 73 | 74 | const handleActiveDeviceChange = (deviceId: string) => { 75 | setActiveMediaDevice(deviceId); 76 | onActiveDeviceChange?.(deviceId); 77 | }; 78 | 79 | const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices]); 80 | 81 | if (filteredDevices.length < 2) { 82 | return null; 83 | } 84 | 85 | return ( 86 | 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/track-selector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | BarVisualizer, 5 | type TrackReferenceOrPlaceholder, 6 | useTrackToggle, 7 | } from '@livekit/components-react'; 8 | import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select'; 9 | import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle'; 10 | import { cn } from '@/lib/utils'; 11 | 12 | interface TrackSelectorProps { 13 | kind: MediaDeviceKind; 14 | source: Parameters[0]['source']; 15 | pressed?: boolean; 16 | pending?: boolean; 17 | disabled?: boolean; 18 | className?: string; 19 | audioTrackRef?: TrackReferenceOrPlaceholder; 20 | onPressedChange?: (pressed: boolean) => void; 21 | onMediaDeviceError?: (error: Error) => void; 22 | onActiveDeviceChange?: (deviceId: string) => void; 23 | } 24 | 25 | export function TrackSelector({ 26 | kind, 27 | source, 28 | pressed, 29 | pending, 30 | disabled, 31 | className, 32 | audioTrackRef, 33 | onPressedChange, 34 | onMediaDeviceError, 35 | onActiveDeviceChange, 36 | }: TrackSelectorProps) { 37 | return ( 38 |
39 | 49 | {audioTrackRef && ( 50 | 56 | 63 | 64 | )} 65 | 66 |
67 | 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /components/livekit/agent-control-bar/track-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { Track } from 'livekit-client'; 5 | import { useTrackToggle } from '@livekit/components-react'; 6 | import { 7 | MicrophoneIcon, 8 | MicrophoneSlashIcon, 9 | MonitorArrowUpIcon, 10 | SpinnerIcon, 11 | VideoCameraIcon, 12 | VideoCameraSlashIcon, 13 | } from '@phosphor-icons/react/dist/ssr'; 14 | import { Toggle } from '@/components/livekit/toggle'; 15 | import { cn } from '@/lib/utils'; 16 | 17 | function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) { 18 | if (pending) { 19 | return SpinnerIcon; 20 | } 21 | 22 | switch (source) { 23 | case Track.Source.Microphone: 24 | return enabled ? MicrophoneIcon : MicrophoneSlashIcon; 25 | case Track.Source.Camera: 26 | return enabled ? VideoCameraIcon : VideoCameraSlashIcon; 27 | case Track.Source.ScreenShare: 28 | return MonitorArrowUpIcon; 29 | default: 30 | return React.Fragment; 31 | } 32 | } 33 | 34 | export type TrackToggleProps = React.ComponentProps & { 35 | source: Parameters[0]['source']; 36 | pending?: boolean; 37 | }; 38 | 39 | export function TrackToggle({ source, pressed, pending, className, ...props }: TrackToggleProps) { 40 | const IconComponent = getSourceIcon(source, pressed ?? false, pending); 41 | 42 | return ( 43 | 44 | 45 | {props.children} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /components/livekit/alert-toast.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ReactNode } from 'react'; 4 | import { toast as sonnerToast } from 'sonner'; 5 | import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; 6 | import { Alert, AlertDescription, AlertTitle } from '@/components/livekit/alert'; 7 | 8 | interface ToastProps { 9 | id: string | number; 10 | title: ReactNode; 11 | description: ReactNode; 12 | } 13 | 14 | export function toastAlert(toast: Omit) { 15 | return sonnerToast.custom( 16 | (id) => , 17 | { duration: 10_000 } 18 | ); 19 | } 20 | 21 | export function AlertToast(props: ToastProps) { 22 | const { title, description, id } = props; 23 | 24 | return ( 25 | sonnerToast.dismiss(id)} className="bg-accent w-full md:w-[364px]"> 26 | 27 | {title} 28 | {description && {description}} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/livekit/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { type VariantProps, cva } from 'class-variance-authority'; 3 | import { cn } from '@/lib/utils'; 4 | 5 | const alertVariants = cva( 6 | [ 7 | 'relative w-full rounded-lg border px-4 py-3 text-sm grid grid-cols-[0_1fr] gap-y-0.5 items-start', 8 | 'has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', 9 | ], 10 | { 11 | variants: { 12 | variant: { 13 | default: 'bg-card text-card-foreground', 14 | destructive: [ 15 | 'text-destructive bg-destructive/10 border-destructive/20', 16 | '[&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', 17 | ], 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: 'default', 22 | }, 23 | } 24 | ); 25 | 26 | function Alert({ 27 | className, 28 | variant, 29 | ...props 30 | }: React.ComponentProps<'div'> & VariantProps) { 31 | return ( 32 |
38 | ); 39 | } 40 | 41 | function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { 42 | return ( 43 |
48 | ); 49 | } 50 | 51 | function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) { 52 | return ( 53 |
61 | ); 62 | } 63 | 64 | export { Alert, AlertTitle, AlertDescription, alertVariants }; 65 | -------------------------------------------------------------------------------- /components/livekit/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { type VariantProps, cva } from 'class-variance-authority'; 3 | import { Slot } from '@radix-ui/react-slot'; 4 | import { cn } from '@/lib/utils'; 5 | 6 | const buttonVariants = cva( 7 | [ 8 | 'text-xs font-bold tracking-wider uppercase whitespace-nowrap', 9 | 'inline-flex items-center justify-center gap-2 shrink-0 rounded-full cursor-pointer outline-none transition-colors duration-300', 10 | 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', 11 | 'disabled:pointer-events-none disabled:opacity-50', 12 | 'aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40 ', 13 | "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0", 14 | ], 15 | { 16 | variants: { 17 | variant: { 18 | default: 'bg-muted text-foreground hover:bg-muted focus:bg-muted hover:bg-foreground/10', 19 | destructive: [ 20 | 'bg-destructive/10 text-destructive', 21 | 'hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20', 22 | 'dark:focus-visible:ring-destructive/40', 23 | ], 24 | outline: [ 25 | 'border border-input bg-background', 26 | 'hover:bg-accent hover:text-accent-foreground', 27 | 'dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 28 | ], 29 | primary: 'bg-primary text-primary-foreground hover:bg-primary/70 focus:bg-primary/70', 30 | secondary: 'bg-foreground/15 text-secondary-foreground hover:bg-foreground/20', 31 | ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 32 | link: 'text-primary underline-offset-4 hover:underline', 33 | }, 34 | size: { 35 | default: 'h-9 px-4 py-2 has-[>svg]:px-3', 36 | sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5', 37 | lg: 'h-10 px-6 has-[>svg]:px-4', 38 | icon: 'size-9', 39 | }, 40 | }, 41 | defaultVariants: { 42 | variant: 'default', 43 | size: 'default', 44 | }, 45 | } 46 | ); 47 | 48 | function Button({ 49 | className, 50 | variant, 51 | size, 52 | asChild = false, 53 | ...props 54 | }: React.ComponentProps<'button'> & 55 | VariantProps & { 56 | asChild?: boolean; 57 | }) { 58 | const Comp = asChild ? Slot : 'button'; 59 | 60 | return ( 61 | 66 | ); 67 | } 68 | 69 | export { Button, buttonVariants }; 70 | -------------------------------------------------------------------------------- /components/livekit/chat-entry.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cn } from '@/lib/utils'; 3 | 4 | export interface ChatEntryProps extends React.HTMLAttributes { 5 | /** The locale to use for the timestamp. */ 6 | locale: string; 7 | /** The timestamp of the message. */ 8 | timestamp: number; 9 | /** The message to display. */ 10 | message: string; 11 | /** The origin of the message. */ 12 | messageOrigin: 'local' | 'remote'; 13 | /** The sender's name. */ 14 | name?: string; 15 | /** Whether the message has been edited. */ 16 | hasBeenEdited?: boolean; 17 | } 18 | 19 | export const ChatEntry = ({ 20 | name, 21 | locale, 22 | timestamp, 23 | message, 24 | messageOrigin, 25 | hasBeenEdited = false, 26 | className, 27 | ...props 28 | }: ChatEntryProps) => { 29 | const time = new Date(timestamp); 30 | const title = time.toLocaleTimeString(locale, { timeStyle: 'full' }); 31 | 32 | return ( 33 |
  • 39 |
    45 | {name && {name}} 46 | 47 | {hasBeenEdited && '*'} 48 | {time.toLocaleTimeString(locale, { timeStyle: 'short' })} 49 | 50 |
    51 | 57 | {message} 58 | 59 |
  • 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /components/livekit/scroll-area/hooks/useAutoScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const AUTO_SCROLL_THRESHOLD_PX = 100; 4 | 5 | export function useAutoScroll(scrollContentContainer?: Element | null) { 6 | useEffect(() => { 7 | function scrollToBottom() { 8 | if (!scrollContentContainer) return; 9 | 10 | const distanceFromBottom = 11 | scrollContentContainer.scrollHeight - 12 | scrollContentContainer.clientHeight - 13 | scrollContentContainer.scrollTop; 14 | 15 | if (distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX) { 16 | scrollContentContainer.scrollTop = scrollContentContainer.scrollHeight; 17 | } 18 | } 19 | 20 | if (scrollContentContainer && scrollContentContainer.firstElementChild) { 21 | const resizeObserver = new ResizeObserver(scrollToBottom); 22 | 23 | resizeObserver.observe(scrollContentContainer.firstElementChild); 24 | scrollToBottom(); 25 | 26 | return () => resizeObserver.disconnect(); 27 | } 28 | }, [scrollContentContainer]); 29 | } 30 | -------------------------------------------------------------------------------- /components/livekit/scroll-area/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { forwardRef, useCallback, useRef } from 'react'; 4 | import { useAutoScroll } from '@/components/livekit/scroll-area/hooks/useAutoScroll'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | interface ScrollAreaProps { 8 | children?: React.ReactNode; 9 | className?: string; 10 | } 11 | 12 | export const ScrollArea = forwardRef(function ScrollArea( 13 | { className, children }, 14 | ref 15 | ) { 16 | const scrollContentRef = useRef(null); 17 | 18 | useAutoScroll(scrollContentRef.current); 19 | 20 | const mergedRef = useCallback( 21 | (node: HTMLDivElement | null) => { 22 | scrollContentRef.current = node; 23 | 24 | if (typeof ref === 'function') { 25 | ref(node); 26 | } else if (ref) { 27 | ref.current = node; 28 | } 29 | }, 30 | [ref] 31 | ); 32 | 33 | return ( 34 |
    35 |
    {children}
    36 |
    37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /components/livekit/select.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { CaretDownIcon, CaretUpIcon, CheckIcon } from '@phosphor-icons/react/dist/ssr'; 5 | import * as SelectPrimitive from '@radix-ui/react-select'; 6 | import { cn } from '@/lib/utils'; 7 | 8 | function Select({ ...props }: React.ComponentProps) { 9 | return ; 10 | } 11 | 12 | function SelectGroup({ ...props }: React.ComponentProps) { 13 | return ; 14 | } 15 | 16 | function SelectValue({ ...props }: React.ComponentProps) { 17 | return ; 18 | } 19 | 20 | function SelectTrigger({ 21 | className, 22 | size = 'default', 23 | children, 24 | ...props 25 | }: React.ComponentProps & { 26 | size?: 'sm' | 'default'; 27 | }) { 28 | return ( 29 | 50 | {children} 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | function SelectContent({ 59 | className, 60 | children, 61 | position = 'popper', 62 | ...props 63 | }: React.ComponentProps) { 64 | return ( 65 | 66 | 87 | 88 | 95 | {children} 96 | 97 | 98 | 99 | 100 | ); 101 | } 102 | 103 | function SelectLabel({ className, ...props }: React.ComponentProps) { 104 | return ( 105 | 110 | ); 111 | } 112 | 113 | function SelectItem({ 114 | className, 115 | children, 116 | ...props 117 | }: React.ComponentProps) { 118 | return ( 119 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | ); 142 | } 143 | 144 | function SelectSeparator({ 145 | className, 146 | ...props 147 | }: React.ComponentProps) { 148 | return ( 149 | 154 | ); 155 | } 156 | 157 | function SelectScrollUpButton({ 158 | className, 159 | ...props 160 | }: React.ComponentProps) { 161 | return ( 162 | 167 | 168 | 169 | ); 170 | } 171 | 172 | function SelectScrollDownButton({ 173 | className, 174 | ...props 175 | }: React.ComponentProps) { 176 | return ( 177 | 182 | 183 | 184 | ); 185 | } 186 | 187 | export { 188 | Select, 189 | SelectContent, 190 | SelectGroup, 191 | SelectItem, 192 | SelectLabel, 193 | SelectScrollDownButton, 194 | SelectScrollUpButton, 195 | SelectSeparator, 196 | SelectTrigger, 197 | SelectValue, 198 | }; 199 | -------------------------------------------------------------------------------- /components/livekit/shimmer-text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '@/lib/utils'; 3 | 4 | interface ShimmerTextProps { 5 | children: React.ReactNode; 6 | className?: string; 7 | } 8 | 9 | export function ShimmerText({ 10 | children, 11 | className, 12 | ref, 13 | }: ShimmerTextProps & React.RefAttributes) { 14 | return ( 15 | 19 | {children} 20 | 21 | ); 22 | } 23 | 24 | export default ShimmerText; 25 | -------------------------------------------------------------------------------- /components/livekit/toaster.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTheme } from 'next-themes'; 4 | import { Toaster as Sonner, ToasterProps } from 'sonner'; 5 | import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; 6 | 7 | export function Toaster({ ...props }: ToasterProps) { 8 | const { theme = 'system' } = useTheme(); 9 | 10 | return ( 11 | , 17 | }} 18 | style={ 19 | { 20 | '--normal-bg': 'var(--popover)', 21 | '--normal-text': 'var(--popover-foreground)', 22 | '--normal-border': 'var(--border)', 23 | } as React.CSSProperties 24 | } 25 | {...props} 26 | /> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/livekit/toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { type VariantProps, cva } from 'class-variance-authority'; 5 | import * as TogglePrimitive from '@radix-ui/react-toggle'; 6 | import { cn } from '@/lib/utils'; 7 | 8 | const toggleVariants = cva( 9 | [ 10 | 'inline-flex items-center justify-center gap-2 rounded-full', 11 | 'text-sm font-medium whitespace-nowrap', 12 | 'cursor-pointer outline-none transition-[color,box-shadow,background-color]', 13 | 'hover:bg-muted hover:text-muted-foreground', 14 | 'disabled:pointer-events-none disabled:opacity-50', 15 | 'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', 16 | 'focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring', 17 | 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive ', 18 | "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0", 19 | ], 20 | { 21 | variants: { 22 | variant: { 23 | default: 'bg-transparent', 24 | primary: 25 | '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', 26 | secondary: 27 | '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', 28 | outline: 29 | 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', 30 | }, 31 | size: { 32 | default: 'h-9 px-4 py-2 has-[>svg]:px-3', 33 | sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5', 34 | lg: 'h-10 px-6 has-[>svg]:px-4', 35 | icon: 'size-9', 36 | }, 37 | }, 38 | defaultVariants: { 39 | variant: 'default', 40 | size: 'default', 41 | }, 42 | } 43 | ); 44 | 45 | function Toggle({ 46 | className, 47 | variant, 48 | size, 49 | ...props 50 | }: React.ComponentProps & VariantProps) { 51 | return ( 52 | 57 | ); 58 | } 59 | 60 | export { Toggle, toggleVariants }; 61 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { FlatCompat } from '@eslint/eslintrc'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends( 14 | 'next/core-web-vitals', 15 | 'next/typescript', 16 | 'plugin:import/recommended', 17 | 'prettier', 18 | 'plugin:prettier/recommended' 19 | ), 20 | ]; 21 | 22 | export default eslintConfig; 23 | -------------------------------------------------------------------------------- /fonts/CommitMono-400-Italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/fonts/CommitMono-400-Italic.otf -------------------------------------------------------------------------------- /fonts/CommitMono-400-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/fonts/CommitMono-400-Regular.otf -------------------------------------------------------------------------------- /fonts/CommitMono-700-Italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/fonts/CommitMono-700-Italic.otf -------------------------------------------------------------------------------- /fonts/CommitMono-700-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/fonts/CommitMono-700-Regular.otf -------------------------------------------------------------------------------- /hooks/useAgentErrors.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useAgent, useSessionContext } from '@livekit/components-react'; 3 | import { toastAlert } from '@/components/livekit/alert-toast'; 4 | 5 | export function useAgentErrors() { 6 | const agent = useAgent(); 7 | const { isConnected, end } = useSessionContext(); 8 | 9 | useEffect(() => { 10 | if (isConnected && agent.state === 'failed') { 11 | const reasons = agent.failureReasons; 12 | 13 | toastAlert({ 14 | title: 'Session ended', 15 | description: ( 16 | <> 17 | {reasons.length > 1 && ( 18 |
      19 | {reasons.map((reason) => ( 20 |
    • {reason}
    • 21 | ))} 22 |
    23 | )} 24 | {reasons.length === 1 &&

    {reasons[0]}

    } 25 |

    26 | 32 | See quickstart guide 33 | 34 | . 35 |

    36 | 37 | ), 38 | }); 39 | 40 | end(); 41 | } 42 | }, [agent, isConnected, end]); 43 | } 44 | -------------------------------------------------------------------------------- /hooks/useDebug.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { LogLevel, setLogLevel } from 'livekit-client'; 3 | import { useRoomContext } from '@livekit/components-react'; 4 | 5 | export const useDebugMode = (options: { logLevel?: LogLevel; enabled?: boolean } = {}) => { 6 | const room = useRoomContext(); 7 | const logLevel = options.logLevel ?? 'debug'; 8 | const enabled = options.enabled ?? true; 9 | 10 | React.useEffect(() => { 11 | if (!enabled) { 12 | setLogLevel('silent'); 13 | return; 14 | } 15 | 16 | setLogLevel(logLevel ?? 'debug'); 17 | 18 | // @ts-expect-error this is a global variable 19 | window.__lk_room = room; 20 | 21 | return () => { 22 | // @ts-expect-error this is a global variable 23 | window.__lk_room = undefined; 24 | setLogLevel('silent'); 25 | }; 26 | }, [room, enabled, logLevel]); 27 | }; 28 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { type ClassValue, clsx } from 'clsx'; 3 | import { TokenSource } from 'livekit-client'; 4 | import { twMerge } from 'tailwind-merge'; 5 | import { APP_CONFIG_DEFAULTS } from '@/app-config'; 6 | import type { AppConfig } from '@/app-config'; 7 | 8 | export const CONFIG_ENDPOINT = process.env.NEXT_PUBLIC_APP_CONFIG_ENDPOINT; 9 | export const SANDBOX_ID = process.env.SANDBOX_ID; 10 | 11 | export interface SandboxConfig { 12 | [key: string]: 13 | | { type: 'string'; value: string } 14 | | { type: 'number'; value: number } 15 | | { type: 'boolean'; value: boolean } 16 | | null; 17 | } 18 | 19 | export function cn(...inputs: ClassValue[]) { 20 | return twMerge(clsx(inputs)); 21 | } 22 | 23 | /** 24 | * Get the app configuration 25 | * @param headers - The headers of the request 26 | * @returns The app configuration 27 | * 28 | * @note React will invalidate the cache for all memoized functions for each server request. 29 | * https://react.dev/reference/react/cache#caveats 30 | */ 31 | export const getAppConfig = cache(async (headers: Headers): Promise => { 32 | if (CONFIG_ENDPOINT) { 33 | const sandboxId = SANDBOX_ID ?? headers.get('x-sandbox-id') ?? ''; 34 | 35 | try { 36 | if (!sandboxId) { 37 | throw new Error('Sandbox ID is required'); 38 | } 39 | 40 | const response = await fetch(CONFIG_ENDPOINT, { 41 | cache: 'no-store', 42 | headers: { 'X-Sandbox-ID': sandboxId }, 43 | }); 44 | 45 | if (response.ok) { 46 | const remoteConfig: SandboxConfig = await response.json(); 47 | 48 | const config: AppConfig = { ...APP_CONFIG_DEFAULTS, sandboxId }; 49 | 50 | for (const [key, entry] of Object.entries(remoteConfig)) { 51 | if (entry === null) continue; 52 | // Only include app config entries that are declared in defaults and, if set, 53 | // share the same primitive type as the default value. 54 | if ( 55 | (key in APP_CONFIG_DEFAULTS && 56 | APP_CONFIG_DEFAULTS[key as keyof AppConfig] === undefined) || 57 | (typeof config[key as keyof AppConfig] === entry.type && 58 | typeof config[key as keyof AppConfig] === typeof entry.value) 59 | ) { 60 | // @ts-expect-error I'm not sure quite how to appease TypeScript, but we've thoroughly checked types above 61 | config[key as keyof AppConfig] = entry.value as AppConfig[keyof AppConfig]; 62 | } 63 | } 64 | 65 | return config; 66 | } else { 67 | console.error( 68 | `ERROR: querying config endpoint failed with status ${response.status}: ${response.statusText}` 69 | ); 70 | } 71 | } catch (error) { 72 | console.error('ERROR: getAppConfig() - lib/utils.ts', error); 73 | } 74 | } 75 | 76 | return APP_CONFIG_DEFAULTS; 77 | }); 78 | 79 | /** 80 | * Get styles for the app 81 | * @param appConfig - The app configuration 82 | * @returns A string of styles 83 | */ 84 | export function getStyles(appConfig: AppConfig) { 85 | const { accent, accentDark } = appConfig; 86 | 87 | return [ 88 | accent 89 | ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }` 90 | : '', 91 | accentDark 92 | ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }` 93 | : '', 94 | ] 95 | .filter(Boolean) 96 | .join('\n'); 97 | } 98 | 99 | /** 100 | * Get a token source for a sandboxed LiveKit session 101 | * @param appConfig - The app configuration 102 | * @returns A token source for a sandboxed LiveKit session 103 | */ 104 | export function getSandboxTokenSource(appConfig: AppConfig) { 105 | return TokenSource.custom(async () => { 106 | const url = new URL(process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT!, window.location.origin); 107 | const sandboxId = appConfig.sandboxId ?? ''; 108 | const roomConfig = appConfig.agentName 109 | ? { 110 | agents: [{ agent_name: appConfig.agentName }], 111 | } 112 | : undefined; 113 | 114 | try { 115 | const res = await fetch(url.toString(), { 116 | method: 'POST', 117 | headers: { 118 | 'Content-Type': 'application/json', 119 | 'X-Sandbox-Id': sandboxId, 120 | }, 121 | body: JSON.stringify({ 122 | room_config: roomConfig, 123 | }), 124 | }); 125 | return await res.json(); 126 | } catch (error) { 127 | console.error('Error fetching connection details:', error); 128 | throw new Error('Error fetching connection details!'); 129 | } 130 | }); 131 | } 132 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agent-starter-react", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format": "prettier --write .", 11 | "format:check": "prettier --check ." 12 | }, 13 | "dependencies": { 14 | "@livekit/components-react": "^2.9.15", 15 | "@livekit/protocol": "^1.40.0", 16 | "@phosphor-icons/react": "^2.1.8", 17 | "@radix-ui/react-select": "^2.2.5", 18 | "@radix-ui/react-slot": "^1.2.3", 19 | "@radix-ui/react-toggle": "^1.1.9", 20 | "buffer-image-size": "^0.6.4", 21 | "class-variance-authority": "^0.7.1", 22 | "clsx": "^2.1.1", 23 | "jose": "^6.0.12", 24 | "livekit-client": "^2.15.15", 25 | "livekit-server-sdk": "^2.13.2", 26 | "mime": "^4.0.7", 27 | "motion": "^12.16.0", 28 | "next": "15.5.7", 29 | "next-themes": "^0.4.6", 30 | "react": "^19.0.0", 31 | "react-dom": "^19.0.0", 32 | "sonner": "^2.0.7", 33 | "tailwind-merge": "^3.3.0" 34 | }, 35 | "devDependencies": { 36 | "@eslint/eslintrc": "^3", 37 | "@tailwindcss/postcss": "^4", 38 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 39 | "@types/node": "^22.0.0", 40 | "@types/react": "^19", 41 | "@types/react-dom": "^19", 42 | "eslint": "^9", 43 | "eslint-config-next": "15.5.2", 44 | "eslint-config-prettier": "^10.1.5", 45 | "eslint-plugin-import": "^2.31.0", 46 | "eslint-plugin-prettier": "^5.5.0", 47 | "prettier": "^3.4.2", 48 | "prettier-plugin-tailwindcss": "^0.6.11", 49 | "tailwindcss": "^4", 50 | "tw-animate-css": "^1.3.0", 51 | "typescript": "^5" 52 | }, 53 | "packageManager": "pnpm@9.15.9" 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ['@tailwindcss/postcss'], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/commit-mono-400-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/public/commit-mono-400-regular.woff -------------------------------------------------------------------------------- /public/everett-light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/public/everett-light.woff -------------------------------------------------------------------------------- /public/lk-logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/lk-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/lk-wordmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/opengraph-image-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/agent-starter-react/HEAD/public/opengraph-image-bg.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "packageRules": [ 5 | { 6 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 7 | "automerge": true 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'tw-animate-css'; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | :root { 7 | --radius: 0.625rem; 8 | --background: oklch(1 0 0); 9 | --foreground: oklch(0.145 0 0); 10 | --card: oklch(1 0 0); 11 | --card-foreground: oklch(0.145 0 0); 12 | --popover: oklch(1 0 0); 13 | --popover-foreground: oklch(0.145 0 0); 14 | --primary: oklch(0.205 0 0); 15 | --primary-foreground: oklch(0.985 0 0); 16 | --secondary: oklch(0.97 0 0); 17 | --secondary-foreground: oklch(0.205 0 0); 18 | --muted: oklch(0.97 0 0); 19 | --muted-foreground: oklch(0.556 0 0); 20 | --accent: oklch(0.97 0 0); 21 | --accent-foreground: oklch(0.205 0 0); 22 | --destructive: oklch(0.577 0.245 27.325); 23 | --border: oklch(0.922 0 0); 24 | --input: oklch(0.922 0 0); 25 | --ring: oklch(0.708 0 0); 26 | --chart-1: oklch(0.646 0.222 41.116); 27 | --chart-2: oklch(0.6 0.118 184.704); 28 | --chart-3: oklch(0.398 0.07 227.392); 29 | --chart-4: oklch(0.828 0.189 84.429); 30 | --chart-5: oklch(0.769 0.188 70.08); 31 | --sidebar: oklch(0.985 0 0); 32 | --sidebar-foreground: oklch(0.145 0 0); 33 | --sidebar-primary: oklch(0.205 0 0); 34 | --sidebar-primary-foreground: oklch(0.985 0 0); 35 | --sidebar-accent: oklch(0.97 0 0); 36 | --sidebar-accent-foreground: oklch(0.205 0 0); 37 | --sidebar-border: oklch(0.922 0 0); 38 | --sidebar-ring: oklch(0.708 0 0); 39 | } 40 | 41 | .dark { 42 | --background: oklch(0.145 0 0); 43 | --foreground: oklch(0.985 0 0); 44 | --card: oklch(0.205 0 0); 45 | --card-foreground: oklch(0.985 0 0); 46 | --popover: oklch(0.269 0 0); 47 | --popover-foreground: oklch(0.985 0 0); 48 | --primary: oklch(0.922 0 0); 49 | --primary-foreground: oklch(0.205 0 0); 50 | --secondary: oklch(0.269 0 0); 51 | --secondary-foreground: oklch(0.985 0 0); 52 | --muted: oklch(0.269 0 0); 53 | --muted-foreground: oklch(0.708 0 0); 54 | --accent: oklch(0.371 0 0); 55 | --accent-foreground: oklch(0.985 0 0); 56 | --destructive: oklch(0.704 0.191 22.216); 57 | --border: oklch(1 0 0 / 10%); 58 | --input: oklch(1 0 0 / 15%); 59 | --ring: oklch(0.556 0 0); 60 | --chart-1: oklch(0.488 0.243 264.376); 61 | --chart-2: oklch(0.696 0.17 162.48); 62 | --chart-3: oklch(0.769 0.188 70.08); 63 | --chart-4: oklch(0.627 0.265 303.9); 64 | --chart-5: oklch(0.645 0.246 16.439); 65 | --sidebar: oklch(0.205 0 0); 66 | --sidebar-foreground: oklch(0.985 0 0); 67 | --sidebar-primary: oklch(0.488 0.243 264.376); 68 | --sidebar-primary-foreground: oklch(0.985 0 0); 69 | --sidebar-accent: oklch(0.269 0 0); 70 | --sidebar-accent-foreground: oklch(0.985 0 0); 71 | --sidebar-border: oklch(1 0 0 / 10%); 72 | --sidebar-ring: oklch(0.439 0 0); 73 | } 74 | 75 | @theme inline { 76 | --font-sans: 77 | var(--font-public-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 78 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 79 | --font-mono: 80 | var(--font-commit-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 81 | 'Liberation Mono', 'Courier New', monospace; 82 | 83 | --color-background: var(--background); 84 | --color-foreground: var(--foreground); 85 | --color-card: var(--card); 86 | --color-card-foreground: var(--card-foreground); 87 | --color-popover: var(--popover); 88 | --color-popover-foreground: var(--popover-foreground); 89 | --color-primary: var(--primary); 90 | --color-primary-foreground: var(--primary-foreground); 91 | --color-secondary: var(--secondary); 92 | --color-secondary-foreground: var(--secondary-foreground); 93 | --color-muted: var(--muted); 94 | --color-muted-foreground: var(--muted-foreground); 95 | --color-accent: var(--accent); 96 | --color-accent-foreground: var(--accent-foreground); 97 | --color-destructive: var(--destructive); 98 | --color-border: var(--border); 99 | --color-input: var(--input); 100 | --color-ring: var(--ring); 101 | --color-chart-1: var(--chart-1); 102 | --color-chart-2: var(--chart-2); 103 | --color-chart-3: var(--chart-3); 104 | --color-chart-4: var(--chart-4); 105 | --color-chart-5: var(--chart-5); 106 | --color-sidebar: var(--sidebar); 107 | --color-sidebar-foreground: var(--sidebar-foreground); 108 | --color-sidebar-primary: var(--sidebar-primary); 109 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 110 | --color-sidebar-accent: var(--sidebar-accent); 111 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 112 | --color-sidebar-border: var(--sidebar-border); 113 | --color-sidebar-ring: var(--sidebar-ring); 114 | } 115 | 116 | @layer base { 117 | * { 118 | @apply border-foreground/20 outline-ring/50; 119 | } 120 | body { 121 | @apply bg-background text-foreground; 122 | } 123 | } 124 | 125 | @layer utils { 126 | .animate-text-shimmer { 127 | animation-delay: 0.5s; 128 | animation-duration: 3s; 129 | animation-iteration-count: infinite; 130 | animation-name: text-shimmer; 131 | background: var(--muted-foreground) 132 | gradient( 133 | linear, 134 | 100% 0, 135 | 0 0, 136 | from(var(--muted-foreground)), 137 | color-stop(0.5, var(--secondary-foreground)), 138 | to(var(--muted-foreground)) 139 | ); 140 | background: var(--muted-foreground) -webkit-gradient( 141 | linear, 142 | 100% 0, 143 | 0 0, 144 | from(var(--muted-foreground)), 145 | color-stop(0.5, var(--secondary-foreground)), 146 | to(var(--muted-foreground)) 147 | ); 148 | background-repeat: no-repeat; 149 | background-size: 50% 200%; 150 | display: inline-block; 151 | } 152 | 153 | @keyframes text-shimmer { 154 | 0% { 155 | background-position: -100% 0; 156 | } 157 | 100% { 158 | background-position: 250% 0; 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | output: interleaved 3 | dotenv: ['.env.local'] 4 | 5 | tasks: 6 | post_create: 7 | desc: 'Runs after this template is instantiated as a Sandbox or Bootstrap' 8 | cmds: 9 | - echo -e "\nYour Next.js voice assistant is ready to go!\n" 10 | - echo -e "To give it a try, run the following commands:\r\n" 11 | - echo -e "\tcd {{.ROOT_DIR}}\r" 12 | - echo -e "\tpnpm install\r" 13 | - echo -e "\tpnpm dev\r\n" 14 | 15 | install: 16 | interactive: true 17 | cmds: 18 | - 'pnpm install' 19 | 20 | dev: 21 | interactive: true 22 | cmds: 23 | - 'pnpm dev' 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------