Skip to content

Commit 23cb7a9

Browse files
committed
feat: add support for explicit agent dispatch via config
1 parent e1378ec commit 23cb7a9

File tree

10 files changed

+141
-70
lines changed

10 files changed

+141
-70
lines changed

app-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
1515
logoDark: '/lk-logo-dark.svg',
1616
accentDark: '#1fd5f9',
1717
startButtonText: 'Start call',
18+
19+
agentName: undefined,
1820
};

app/api/connection-details/route.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type ConnectionDetails = {
1616
participantToken: string;
1717
};
1818

19-
export async function GET() {
19+
export async function POST(req: Request) {
2020
try {
2121
if (LIVEKIT_URL === undefined) {
2222
throw new Error('LIVEKIT_URL is not defined');
@@ -28,13 +28,19 @@ export async function GET() {
2828
throw new Error('LIVEKIT_API_SECRET is not defined');
2929
}
3030

31+
// Parse agent configuration from request body
32+
const body = await req.json();
33+
const agentName: string = body?.room_config?.agents?.[0]?.agent_name;
34+
3135
// Generate participant token
3236
const participantName = 'user';
3337
const participantIdentity = `voice_assistant_user_${Math.floor(Math.random() * 10_000)}`;
3438
const roomName = `voice_assistant_room_${Math.floor(Math.random() * 10_000)}`;
39+
3540
const participantToken = await createParticipantToken(
3641
{ identity: participantIdentity, name: participantName },
37-
roomName
42+
roomName,
43+
agentName
3844
);
3945

4046
// Return connection details
@@ -56,7 +62,11 @@ export async function GET() {
5662
}
5763
}
5864

59-
function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
65+
function createParticipantToken(
66+
userInfo: AccessTokenOptions,
67+
roomName: string,
68+
agentName?: string
69+
): Promise<string> {
6070
const at = new AccessToken(API_KEY, API_SECRET, {
6171
...userInfo,
6272
ttl: '15m',
@@ -69,5 +79,15 @@ function createParticipantToken(userInfo: AccessTokenOptions, roomName: string)
6979
canSubscribe: true,
7080
};
7181
at.addGrant(grant);
82+
83+
if (agentName) {
84+
at.roomConfig = {
85+
agents: [
86+
// @ts-expect-error - RoomAgentDispatch is not constructable
87+
{ agentName },
88+
],
89+
};
90+
}
91+
7292
return at.toJwt();
7393
}

app/components/Tabs.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { cn } from '@/lib/utils';
5+
import { usePathname } from 'next/navigation';
6+
7+
export function Tabs() {
8+
const pathname = usePathname();
9+
10+
return (<div className="flex flex-row justify-between border-b">
11+
<Link
12+
href="/components/base"
13+
className={cn(
14+
'text-fg0 -mb-px cursor-pointer px-4 pt-2 text-xl font-bold tracking-tight uppercase',
15+
pathname === '/components/base' &&
16+
'bg-background rounded-t-lg border-t border-r border-l'
17+
)}
18+
>
19+
Base components
20+
</Link>
21+
<Link
22+
href="/components/livekit"
23+
className={cn(
24+
'text-fg0 -mb-px cursor-pointer px-4 py-2 text-xl font-bold tracking-tight uppercase',
25+
pathname === '/components/livekit' &&
26+
'bg-background rounded-t-lg border-t border-r border-l'
27+
)}
28+
>
29+
LiveKit components
30+
</Link>
31+
</div>
32+
);
33+
}

app/components/layout.tsx

Lines changed: 12 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,14 @@
1-
'use client';
2-
31
import * as React from 'react';
4-
import Link from 'next/link';
5-
import { usePathname } from 'next/navigation';
6-
import { Room } from 'livekit-client';
7-
import { RoomContext } from '@livekit/components-react';
8-
import { toastAlert } from '@/components/alert-toast';
9-
import useConnectionDetails from '@/hooks/useConnectionDetails';
10-
import { cn } from '@/lib/utils';
11-
12-
export default function ComponentsLayout({ children }: { children: React.ReactNode }) {
13-
const { connectionDetails } = useConnectionDetails();
14-
15-
const pathname = usePathname();
16-
const room = React.useMemo(() => new Room(), []);
17-
18-
React.useEffect(() => {
19-
if (room.state === 'disconnected' && connectionDetails) {
20-
Promise.all([
21-
room.localParticipant.setMicrophoneEnabled(true, undefined, {
22-
preConnectBuffer: true,
23-
}),
24-
room.connect(connectionDetails.serverUrl, connectionDetails.participantToken),
25-
]).catch((error) => {
26-
toastAlert({
27-
title: 'There was an error connecting to the agent',
28-
description: `${error.name}: ${error.message}`,
29-
});
30-
});
31-
}
32-
return () => {
33-
room.disconnect();
34-
};
35-
}, [room, connectionDetails]);
2+
import { cn, getAppConfig } from '@/lib/utils';
3+
import { headers } from 'next/headers';
4+
import { Provider } from '@/components/provider';
5+
import { Tabs } from '@/app/components/Tabs';
366

7+
export default async function ComponentsLayout({ children }: {
8+
children: React.ReactNode;
9+
}) {
10+
const hdrs = await headers();
11+
const appConfig = await getAppConfig(hdrs);
3712
return (
3813
<div className="mx-auto min-h-svh max-w-3xl space-y-8 px-4 py-8">
3914
<header className="flex flex-col gap-1">
@@ -42,33 +17,10 @@ export default function ComponentsLayout({ children }: { children: React.ReactNo
4217
A quick start UI overview for the LiveKit Voice Assistant.
4318
</p>
4419
</header>
45-
46-
<div className="flex flex-row justify-between border-b">
47-
<Link
48-
href="/components/base"
49-
className={cn(
50-
'text-fg0 -mb-px cursor-pointer px-4 pt-2 text-xl font-bold tracking-tight uppercase',
51-
pathname === '/components/base' &&
52-
'bg-background rounded-t-lg border-t border-r border-l'
53-
)}
54-
>
55-
Base components
56-
</Link>
57-
<Link
58-
href="/components/livekit"
59-
className={cn(
60-
'text-fg0 -mb-px cursor-pointer px-4 py-2 text-xl font-bold tracking-tight uppercase',
61-
pathname === '/components/livekit' &&
62-
'bg-background rounded-t-lg border-t border-r border-l'
63-
)}
64-
>
65-
LiveKit components
66-
</Link>
67-
</div>
68-
69-
<RoomContext.Provider value={room}>
20+
<Tabs />
21+
<Provider appConfig={appConfig}>
7022
<main className="flex w-full flex-1 flex-col items-stretch gap-8">{children}</main>
71-
</RoomContext.Provider>
23+
</Provider>
7224
</div>
7325
);
7426
}

components/app.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface AppProps {
2121
export function App({ appConfig }: AppProps) {
2222
const room = useMemo(() => new Room(), []);
2323
const [sessionStarted, setSessionStarted] = useState(false);
24-
const { refreshConnectionDetails, existingOrRefreshConnectionDetails } = useConnectionDetails();
24+
const { refreshConnectionDetails, existingOrRefreshConnectionDetails } = useConnectionDetails(appConfig);
2525

2626
useEffect(() => {
2727
const onDisconnected = () => {

components/pathname.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client';
2+
3+
import { usePathname } from "next/navigation";
4+
5+
export function Pathname({ action }: { action: (pathname: string) => React.ReactNode }) {
6+
const pathname = usePathname();
7+
return <>{action(pathname)}</>;
8+
}

components/provider.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import React from "react";
4+
import { AppConfig } from "@/lib/types";
5+
import { Room } from "livekit-client";
6+
import { toastAlert } from "@/components/alert-toast";
7+
import useConnectionDetails from "@/hooks/useConnectionDetails";
8+
import { RoomContext } from '@livekit/components-react';
9+
10+
export function Provider({ appConfig, children }: { appConfig: AppConfig, children: React.ReactNode }) {
11+
const { connectionDetails } = useConnectionDetails(appConfig);
12+
const room = React.useMemo(() => new Room(), []);
13+
14+
React.useEffect(() => {
15+
if (room.state === 'disconnected' && connectionDetails) {
16+
Promise.all([
17+
room.localParticipant.setMicrophoneEnabled(true, undefined, {
18+
preConnectBuffer: true,
19+
}),
20+
room.connect(connectionDetails.serverUrl, connectionDetails.participantToken),
21+
]).catch((error) => {
22+
toastAlert({
23+
title: 'There was an error connecting to the agent',
24+
description: `${error.name}: ${error.message}`,
25+
});
26+
});
27+
}
28+
return () => {
29+
room.disconnect();
30+
};
31+
}, [room, connectionDetails]);
32+
33+
34+
return (
35+
<RoomContext.Provider value={room}>
36+
{children}
37+
</RoomContext.Provider>
38+
);
39+
}

hooks/useConnectionDetails.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useCallback, useEffect, useState } from 'react';
22
import { decodeJwt } from 'jose';
33
import { ConnectionDetails } from '@/app/api/connection-details/route';
4+
import { AppConfig } from '@/lib/types';
45

56
const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000;
67

7-
export default function useConnectionDetails() {
8+
export default function useConnectionDetails(appConfig: AppConfig) {
89
// Generate room connection details, including:
910
// - A random Room name
1011
// - A random Participant name
@@ -25,7 +26,20 @@ export default function useConnectionDetails() {
2526

2627
let data: ConnectionDetails;
2728
try {
28-
const res = await fetch(url.toString());
29+
const res = await fetch(url.toString(), {
30+
method: 'POST',
31+
headers: {
32+
'Content-Type': 'application/json',
33+
'X-Sandbox-Id': appConfig.sandboxId ?? '',
34+
},
35+
body: JSON.stringify({
36+
room_config: appConfig.agentName
37+
? {
38+
agents: [{ agent_name: appConfig.agentName }],
39+
}
40+
: undefined,
41+
}),
42+
});
2943
data = await res.json();
3044
} catch (error) {
3145
console.error('Error fetching connection details:', error);

lib/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export interface AppConfig {
2222
accent?: string;
2323
logoDark?: string;
2424
accentDark?: string;
25+
26+
sandboxId?: string;
27+
agentName?: string;
2528
}
2629

2730
export interface SandboxConfig {

lib/utils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,14 @@ export const getAppConfig = cache(async (headers: Headers): Promise<AppConfig> =
5050
});
5151

5252
const remoteConfig: SandboxConfig = await response.json();
53-
const config: AppConfig = { ...APP_CONFIG_DEFAULTS };
53+
const config: AppConfig = { sandboxId, ...APP_CONFIG_DEFAULTS };
5454

5555
for (const [key, entry] of Object.entries(remoteConfig)) {
5656
if (entry === null) continue;
5757
if (
58-
key in config &&
59-
typeof config[key as keyof AppConfig] === entry.type &&
60-
typeof config[key as keyof AppConfig] === typeof entry.value
58+
(key in config && config[key as keyof AppConfig] === undefined) ||
59+
(typeof config[key as keyof AppConfig] === entry.type &&
60+
typeof config[key as keyof AppConfig] === typeof entry.value)
6161
) {
6262
// @ts-expect-error I'm not sure quite how to appease TypeScript, but we've thoroughly checked types above
6363
config[key as keyof AppConfig] = entry.value as AppConfig[keyof AppConfig];

0 commit comments

Comments
 (0)