diff --git a/.env.example b/.env.example index 561314069..3607e675d 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,3 @@ LIVEKIT_URL=wss://.livekit.cloud # Leave AGENT_NAME blank to enable automatic dispatch # Provide an agent name to enable explicit dispatch AGENT_NAME= - -# Internally used environment variables -NEXT_PUBLIC_APP_CONFIG_ENDPOINT= -SANDBOX_ID= diff --git a/.github/workflows/sync-to-production.yaml b/.github/workflows/sync-to-production.yaml deleted file mode 100644 index 3922a1b9c..000000000 --- a/.github/workflows/sync-to-production.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# .github/workflows/sync-to-production.yaml -name: Sync main to sandbox-production - -on: - workflow_dispatch: - -jobs: - sync: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: livekit-examples/sandbox-deploy-action@v1 - with: - production_branch: 'sandbox-production' - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 08251e01d..b678c4f35 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Also available for: - Multiple audio visualizer styles (`bar`, `grid`, `radial`, `wave`, `aura`) - Virtual avatar integration - Light/dark theme switching with system preference detection -- Customizable branding, colors, and UI text via configuration +- Customizable branding, colors, and UI text This template is built with Next.js and is free for you to use or modify as you see fit. @@ -102,56 +102,11 @@ You'll also need an agent to speak with. Try our starter agent for [Python](http ## Configuration -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: - -#### Example: App configuration (`app-config.ts`) - -```ts -export const APP_CONFIG_DEFAULTS: AppConfig = { - companyName: 'LiveKit', - pageTitle: 'LiveKit Voice Agent', - pageDescription: 'A voice agent built with LiveKit', - - supportsChatInput: true, - supportsVideoInput: true, - supportsScreenShare: true, - isPreConnectBufferEnabled: true, - - logo: '/lk-logo.svg', - accent: '#002cf2', - logoDark: '/lk-logo-dark.svg', - accentDark: '#1fd5f9', - startButtonText: 'Start call', - - // optional: audio visualization configuration - // audioVisualizerColor: '#002cf2', - // audioVisualizerColorDark: '#1fd5f9', - // audioVisualizerType: 'bar', - // audioVisualizerBarCount: 5, - // audioVisualizerType: 'radial', - // audioVisualizerRadialBarCount: 24, - // audioVisualizerRadialRadius: 100, - // audioVisualizerType: 'grid', - // audioVisualizerGridRowCount: 25, - // audioVisualizerGridColumnCount: 25, - // audioVisualizerType: 'wave', - // audioVisualizerWaveLineWidth: 3, - // audioVisualizerType: 'aura', - // audioVisualizerAuraColorShift: 0.3, - - // agent dispatch configuration - agentName: undefined, - - // LiveKit Cloud Sandbox configuration - sandboxId: undefined, -}; -``` - -You can update these values in [`app-config.ts`](./app-config.ts) to customize branding, features, and UI text for your deployment. +This starter is designed to be flexible so you can adapt it to your specific agent use case. Branding, feature toggles, and UI text are set directly in the components that use them — see [`app/layout.tsx`](./app/layout.tsx) for branding (logo, colors, page title/description) and [`components/app/view-controller.tsx`](./components/app/view-controller.tsx) for feature toggles and the audio visualizer. #### Audio visualizer presets -Set `audioVisualizerType` in [`app-config.ts`](./app-config.ts) to switch visualizer styles: +Set `audioVisualizerType` in [`components/app/view-controller.tsx`](./components/app/view-controller.tsx) to switch visualizer styles: - `bar` (default): vertical bars with optional `audioVisualizerBarCount` - `grid`: dot grid with `audioVisualizerGridRowCount` and `audioVisualizerGridColumnCount` @@ -161,10 +116,6 @@ Set `audioVisualizerType` in [`app-config.ts`](./app-config.ts) to switch visual Use `audioVisualizerColor` to set a shared accent color across all visualizer modes. -> [!NOTE] -> The `sandboxId` is for the LiveKit Cloud Sandbox environment. -> It is not used for local development. - #### Environment Variables You'll also need to configure your LiveKit credentials in `.env.local` (copy `.env.example` if you don't have one): diff --git a/TEMPLATE.md b/TEMPLATE.md deleted file mode 100644 index b93f4c509..000000000 --- a/TEMPLATE.md +++ /dev/null @@ -1,17 +0,0 @@ -## Overview - -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/). - -## Sandbox - -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): - -## Installation - -To run this application locally, clone the repo or use the [LiveKit CLI](https://docs.livekit.io/home/cli/cli-setup/): - -```console -lk app create --template agent-starter-react -``` - -For more information on prototyping using LiveKit Sandbox, see the [documentation](https://docs.livekit.io/home/cloud/sandbox/). diff --git a/app-config.ts b/app-config.ts deleted file mode 100644 index 2e950af5a..000000000 --- a/app-config.ts +++ /dev/null @@ -1,72 +0,0 @@ -export interface AppConfig { - pageTitle: string; - pageDescription: string; - companyName: string; - - supportsChatInput: boolean; - supportsVideoInput: boolean; - supportsScreenShare: boolean; - isPreConnectBufferEnabled: boolean; - - logo: string; - startButtonText: string; - accent?: string; - logoDark?: string; - accentDark?: string; - - audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'; - audioVisualizerColor?: `#${string}`; - audioVisualizerColorDark?: `#${string}`; - audioVisualizerColorShift?: number; - audioVisualizerBarCount?: number; - audioVisualizerGridRowCount?: number; - audioVisualizerGridColumnCount?: number; - audioVisualizerRadialBarCount?: number; - audioVisualizerRadialRadius?: number; - audioVisualizerWaveLineWidth?: number; - - // agent dispatch configuration - agentName?: string; - - // LiveKit Cloud Sandbox configuration - sandboxId?: string; -} - -export const APP_CONFIG_DEFAULTS: AppConfig = { - companyName: 'LiveKit', - pageTitle: 'LiveKit Voice Agent', - pageDescription: 'A voice agent built with LiveKit', - - supportsChatInput: true, - supportsVideoInput: true, - supportsScreenShare: true, - isPreConnectBufferEnabled: true, - - logo: '/lk-logo.svg', - accent: '#002cf2', - logoDark: '/lk-logo-dark.svg', - accentDark: '#1fd5f9', - startButtonText: 'Start call', - - // optional: audio visualization configuration - // audioVisualizerType: 'bar', - // audioVisualizerColor: '#002cf2', - // audioVisualizerColorDark: '#1fd5f9', - // audioVisualizerColorShift: 0.3, - // audioVisualizerBarCount: 5, - // audioVisualizerType: 'radial', - // audioVisualizerRadialBarCount: 24, - // audioVisualizerRadialRadius: 100, - // audioVisualizerType: 'grid', - // audioVisualizerGridRowCount: 25, - // audioVisualizerGridColumnCount: 25, - // audioVisualizerType: 'wave', - // audioVisualizerWaveLineWidth: 3, - // audioVisualizerType: 'aura', - - // agent dispatch configuration - agentName: process.env.AGENT_NAME ?? undefined, - - // LiveKit Cloud Sandbox configuration - sandboxId: undefined, -}; diff --git a/app/layout.tsx b/app/layout.tsx index 46b51d4e2..e09ac9fe8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,8 @@ import { Public_Sans } from 'next/font/google'; import localFont from 'next/font/local'; -import { headers } from 'next/headers'; import { ThemeProvider } from '@/components/app/theme-provider'; import { ThemeToggle } from '@/components/app/theme-toggle'; import { cn } from '@/lib/shadcn/utils'; -import { getAppConfig, getStyles } from '@/lib/utils'; import '@/styles/globals.css'; const publicSans = Public_Sans({ @@ -43,12 +41,7 @@ interface RootLayoutProps { children: React.ReactNode; } -export default async function RootLayout({ children }: RootLayoutProps) { - const hdrs = await headers(); - const appConfig = await getAppConfig(hdrs); - const styles = getStyles(appConfig); - const { pageTitle, pageDescription, companyName, logo, logoDark } = appConfig; - +export default function RootLayout({ children }: RootLayoutProps) { return ( - {styles && } - {pageTitle} - + + LiveKit Voice Agent + {/* eslint-disable-next-line @next/next/no-img-element */} - {`${companyName} + LiveKit Logo {/* eslint-disable-next-line @next/next/no-img-element */} {`${companyName} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx deleted file mode 100644 index 9fccff1e9..000000000 --- a/app/opengraph-image.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { headers } from 'next/headers'; -import { ImageResponse } from 'next/og'; -import getImageSize from 'buffer-image-size'; -import mime from 'mime'; -import { existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { APP_CONFIG_DEFAULTS } from '@/app-config'; -import { getAppConfig } from '@/lib/utils'; - -type Dimensions = { - width: number; - height: number; -}; - -type ImageData = { - base64: string; - dimensions: Dimensions; -}; - -// Image metadata -export const alt = 'About Acme'; -export const size = { - width: 1200, - height: 628, -}; - -function isRemoteFile(uri: string) { - return uri.startsWith('http'); -} - -function doesLocalFileExist(uri: string) { - return existsSync(join(process.cwd(), uri)); -} - -// LOCAL FILES MUST BE IN PUBLIC FOLDER -async function loadFileData(filePath: string): Promise { - if (isRemoteFile(filePath)) { - const response = await fetch(filePath); - if (!response.ok) { - throw new Error(`Failed to fetch ${filePath} - ${response.status} ${response.statusText}`); - } - return await response.arrayBuffer(); - } - - // Try file system first (works in local development) - if (doesLocalFileExist(filePath)) { - const buffer = await readFile(join(process.cwd(), filePath)); - return buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength - ) as ArrayBuffer; - } - - // Fallback to fetching from public URL (works in production) - const publicFilePath = filePath.replace('public/', ''); - const fontUrl = `https://${process.env.VERCEL_URL}/${publicFilePath}`; - - const response = await fetch(fontUrl); - if (!response.ok) { - throw new Error(`Failed to fetch ${fontUrl} - ${response.status} ${response.statusText}`); - } - - return await response.arrayBuffer(); -} - -async function getImageData(uri: string, fallbackUri?: string): Promise { - try { - const fileData = await loadFileData(uri); - const buffer = Buffer.from(fileData); - const mimeType = mime.getType(uri); - - return { - base64: `data:${mimeType};base64,${buffer.toString('base64')}`, - dimensions: getImageSize(buffer), - }; - } catch (e) { - if (fallbackUri) { - return getImageData(fallbackUri, fallbackUri); - } - throw e; - } -} - -function scaleImageSize(size: { width: number; height: number }, desiredHeight: number) { - const scale = desiredHeight / size.height; - return { - width: size.width * scale, - height: desiredHeight, - }; -} - -function cleanPageTitle(appName: string) { - if (appName === APP_CONFIG_DEFAULTS.pageTitle) { - return 'Voice agent'; - } - - return appName; -} - -export const contentType = 'image/png'; - -// Image generation -export default async function Image() { - const hdrs = await headers(); - const appConfig = await getAppConfig(hdrs); - - const pageTitle = cleanPageTitle(appConfig.pageTitle); - const logoUri = appConfig.logoDark || appConfig.logo; - const isLogoUriLocal = logoUri.includes('lk-logo'); - const wordmarkUri = logoUri === APP_CONFIG_DEFAULTS.logoDark ? 'public/lk-wordmark.svg' : logoUri; - - // Load fonts - use file system in dev, fetch in production - let commitMonoData: ArrayBuffer | undefined; - let everettLightData: ArrayBuffer | undefined; - - try { - commitMonoData = await loadFileData('public/commit-mono-400-regular.woff'); - everettLightData = await loadFileData('public/everett-light.woff'); - } catch (e) { - console.error('Failed to load fonts:', e); - // Continue without custom fonts - will fall back to system fonts - } - - // bg - const { base64: bgSrcBase64 } = await getImageData('public/opengraph-image-bg.png'); - - // wordmark - const { base64: wordmarkSrcBase64, dimensions: wordmarkDimensions } = isLogoUriLocal - ? await getImageData(wordmarkUri) - : await getImageData(logoUri); - const wordmarkSize = scaleImageSize(wordmarkDimensions, isLogoUriLocal ? 32 : 64); - - // logo - const { base64: logoSrcBase64, dimensions: logoDimensions } = await getImageData( - logoUri, - 'public/lk-logo-dark.svg' - ); - const logoSize = scaleImageSize(logoDimensions, 24); - - return new ImageResponse( - ( - // ImageResponse JSX element -
- {/* wordmark */} -
- {/* eslint-disable-next-line jsx-a11y/alt-text */} - -
- {/* logo */} -
- {/* eslint-disable-next-line jsx-a11y/alt-text */} - -
- {/* title */} -
-
- SANDBOX -
-
- {pageTitle} -
-
-
- ), - // ImageResponse options - { - // For convenience, we can re-use the exported opengraph-image - // size config to also set the ImageResponse's width and height. - ...size, - fonts: [ - ...(commitMonoData - ? [ - { - name: 'CommitMono', - data: commitMonoData, - style: 'normal' as const, - weight: 400 as const, - }, - ] - : []), - ...(everettLightData - ? [ - { - name: 'Everett', - data: everettLightData, - style: 'normal' as const, - weight: 300 as const, - }, - ] - : []), - ], - } - ); -} diff --git a/app/page.tsx b/app/page.tsx index f46555460..8bbd4da97 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,5 @@ -import { headers } from 'next/headers'; import { App } from '@/components/app/app'; -import { getAppConfig } from '@/lib/utils'; -export default async function Page() { - const hdrs = await headers(); - const appConfig = await getAppConfig(hdrs); - - return ; +export default function Page() { + return ; } diff --git a/components/app/app.tsx b/components/app/app.tsx index f604cfb5a..5a68728a0 100644 --- a/components/app/app.tsx +++ b/components/app/app.tsx @@ -4,14 +4,12 @@ import { useMemo } from 'react'; import { TokenSource } from 'livekit-client'; import { useSession } from '@livekit/components-react'; import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; -import type { AppConfig } from '@/app-config'; import { AgentSessionProvider } from '@/components/agents-ui/agent-session-provider'; import { StartAudioButton } from '@/components/agents-ui/start-audio-button'; import { ViewController } from '@/components/app/view-controller'; import { Toaster } from '@/components/ui/sonner'; import { useAgentErrors } from '@/hooks/useAgentErrors'; import { useDebugMode } from '@/hooks/useDebug'; -import { getSandboxTokenSource } from '@/lib/utils'; const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production'; @@ -23,26 +21,19 @@ function AppSetup() { } interface AppProps { - appConfig: AppConfig; + agentName?: string; } -export function App({ appConfig }: AppProps) { - const tokenSource = useMemo(() => { - return typeof process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT === 'string' - ? getSandboxTokenSource(appConfig) - : TokenSource.endpoint('/api/token'); - }, [appConfig]); +export function App({ agentName }: AppProps) { + const tokenSource = useMemo(() => TokenSource.endpoint('/api/token'), []); - const session = useSession( - tokenSource, - appConfig.agentName ? { agentName: appConfig.agentName } : undefined - ); + const session = useSession(tokenSource, agentName ? { agentName } : undefined); return (
- +
@@ -43,7 +36,7 @@ export function ViewController({ appConfig }: ViewControllerProps) { )} @@ -52,23 +45,10 @@ export function ViewController({ appConfig }: ViewControllerProps) { )} diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index 8a71efabf..000000000 --- a/lib/utils.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { cache } from 'react'; -import { TokenSource } from 'livekit-client'; -import { APP_CONFIG_DEFAULTS } from '@/app-config'; -import type { AppConfig } from '@/app-config'; - -export const CONFIG_ENDPOINT = process.env.NEXT_PUBLIC_APP_CONFIG_ENDPOINT; -export const SANDBOX_ID = process.env.SANDBOX_ID; - -export interface SandboxConfig { - [key: string]: - | { type: 'string'; value: string } - | { type: 'number'; value: number } - | { type: 'boolean'; value: boolean } - | null; -} - -/** - * Get the app configuration - * @param headers - The headers of the request - * @returns The app configuration - * - * @note React will invalidate the cache for all memoized functions for each server request. - * https://react.dev/reference/react/cache#caveats - */ -export const getAppConfig = cache(async (headers: Headers): Promise => { - if (CONFIG_ENDPOINT) { - const sandboxId = SANDBOX_ID ?? headers.get('x-sandbox-id') ?? ''; - - try { - if (!sandboxId) { - throw new Error('Sandbox ID is required'); - } - - const response = await fetch(CONFIG_ENDPOINT, { - cache: 'no-store', - headers: { 'X-Sandbox-ID': sandboxId }, - }); - - if (response.ok) { - const remoteConfig: SandboxConfig = await response.json(); - - const config: AppConfig = { ...APP_CONFIG_DEFAULTS, sandboxId }; - - for (const [key, entry] of Object.entries(remoteConfig)) { - if (entry === null) continue; - // Only include app config entries that are declared in defaults and, if set, - // share the same primitive type as the default value. - if ( - (key in APP_CONFIG_DEFAULTS && - APP_CONFIG_DEFAULTS[key as keyof AppConfig] === undefined) || - (typeof config[key as keyof AppConfig] === entry.type && - typeof config[key as keyof AppConfig] === typeof entry.value) - ) { - // @ts-expect-error I'm not sure quite how to appease TypeScript, but we've thoroughly checked types above - config[key as keyof AppConfig] = entry.value as AppConfig[keyof AppConfig]; - } - } - - return config; - } else { - console.error( - `ERROR: querying config endpoint failed with status ${response.status}: ${response.statusText}` - ); - } - } catch (error) { - console.error('ERROR: getAppConfig() - lib/utils.ts', error); - } - } - - return APP_CONFIG_DEFAULTS; -}); - -/** - * Get styles for the app - * @param appConfig - The app configuration - * @returns A string of styles - */ -export function getStyles(appConfig: AppConfig) { - const { accent, accentDark } = appConfig; - - return [ - accent - ? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }` - : '', - accentDark - ? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }` - : '', - ] - .filter(Boolean) - .join('\n'); -} - -/** - * Get a token source for a sandboxed LiveKit session - * @param appConfig - The app configuration - * @returns A token source for a sandboxed LiveKit session - */ -export function getSandboxTokenSource(appConfig: AppConfig) { - return TokenSource.custom(async () => { - const url = new URL(process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT!, window.location.origin); - const sandboxId = appConfig.sandboxId ?? ''; - const roomConfig = appConfig.agentName - ? { - agents: [{ agent_name: appConfig.agentName }], - } - : undefined; - - try { - const res = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Sandbox-Id': sandboxId, - }, - body: JSON.stringify({ - room_config: roomConfig, - }), - }); - return await res.json(); - } catch (error) { - console.error('Error fetching connection details:', error); - throw new Error('Error fetching connection details!'); - } - }); -}