Skip to content

Commit 347cfd7

Browse files
--wip-- [skip ci]
1 parent 2ae9535 commit 347cfd7

20 files changed

+793
-140
lines changed

app-config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { AppConfig } from './lib/types';
22

33
export const APP_CONFIG_DEFAULTS: AppConfig = {
4+
sandboxId: undefined,
5+
agentName: undefined,
6+
47
companyName: 'LiveKit',
5-
pageTitle: 'LiveKit Embed',
6-
pageDescription: 'A web embed connected to an agent, built with LiveKit',
78

89
supportsChatInput: true,
910
supportsVideoInput: true,
@@ -14,5 +15,4 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
1415
accent: '#002cf2',
1516
logoDark: '/lk-logo-dark.svg',
1617
accentDark: '#1fd5f9',
17-
startButtonText: 'Start call',
1818
};

app/api/connection-details/route.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse } from 'next/server';
22
import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk';
3+
import { RoomConfiguration } from '@livekit/protocol';
34

45
// NOTE: you are expected to define the following environment variables in `.env.local`:
56
const API_KEY = process.env.LIVEKIT_API_KEY;
@@ -16,7 +17,7 @@ export type ConnectionDetails = {
1617
participantToken: string;
1718
};
1819

19-
export async function GET() {
20+
export async function POST(req: Request) {
2021
try {
2122
if (LIVEKIT_URL === undefined) {
2223
throw new Error('LIVEKIT_URL is not defined');
@@ -28,13 +29,19 @@ export async function GET() {
2829
throw new Error('LIVEKIT_API_SECRET is not defined');
2930
}
3031

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+
3136
// Generate participant token
3237
const participantName = 'user';
3338
const participantIdentity = `voice_assistant_user_${Math.floor(Math.random() * 10_000)}`;
3439
const roomName = `voice_assistant_room_${Math.floor(Math.random() * 10_000)}`;
40+
3541
const participantToken = await createParticipantToken(
3642
{ identity: participantIdentity, name: participantName },
37-
roomName
43+
roomName,
44+
agentName
3845
);
3946

4047
// Return connection details
@@ -56,7 +63,11 @@ export async function GET() {
5663
}
5764
}
5865

59-
function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
66+
function createParticipantToken(
67+
userInfo: AccessTokenOptions,
68+
roomName: string,
69+
agentName?: string
70+
): Promise<string> {
6071
const at = new AccessToken(API_KEY, API_SECRET, {
6172
...userInfo,
6273
ttl: '15m',
@@ -69,5 +80,12 @@ function createParticipantToken(userInfo: AccessTokenOptions, roomName: string)
6980
canSubscribe: true,
7081
};
7182
at.addGrant(grant);
83+
84+
if (agentName) {
85+
at.roomConfig = new RoomConfiguration({
86+
agents: [{ agentName }],
87+
});
88+
}
89+
7290
return at.toJwt();
7391
}

components/embed-iframe/agent-client.tsx

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

2626
const [currentError, setCurrentError] = useState<EmbedErrorDetails | null>(null);
2727

Lines changed: 195 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,215 @@
1-
import { MicrophoneToggle } from '@/components/embed-popup/microphone-toggle';
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useCallback } from 'react';
5+
import { Track } from 'livekit-client';
6+
import { BarVisualizer, useRemoteParticipants } from '@livekit/components-react';
7+
import { ChatTextIcon } from '@phosphor-icons/react/dist/ssr';
28
import { ChatInput } from '@/components/livekit/chat/chat-input';
3-
import { useAgentControlBar } from '@/hooks/use-agent-control-bar';
9+
import { DeviceSelect } from '@/components/livekit/device-select';
10+
import { TrackToggle } from '@/components/livekit/track-toggle';
11+
import { Toggle } from '@/components/ui/toggle';
12+
import { UseAgentControlBarProps, useAgentControlBar } from '@/hooks/use-agent-control-bar';
13+
import { AppConfig } from '@/lib/types';
14+
import { cn } from '@/lib/utils';
415

5-
interface ActionBarProps {
6-
onSend: (message: string) => void;
16+
export interface AgentControlBarProps
17+
extends React.HTMLAttributes<HTMLDivElement>,
18+
UseAgentControlBarProps {
19+
capabilities: Pick<AppConfig, 'supportsChatInput' | 'supportsVideoInput' | 'supportsScreenShare'>;
20+
onChatOpenChange?: (open: boolean) => void;
21+
onSendMessage?: (message: string) => Promise<void>;
22+
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
723
}
824

9-
export function ActionBar({ onSend }: ActionBarProps) {
25+
/**
26+
* A control bar specifically designed for voice assistant interfaces
27+
*/
28+
export function ActionBar({
29+
controls,
30+
saveUserChoices = true,
31+
capabilities,
32+
className,
33+
onSendMessage,
34+
onChatOpenChange,
35+
onDeviceError,
36+
...props
37+
}: AgentControlBarProps) {
38+
const participants = useRemoteParticipants();
39+
const [chatOpen, setChatOpen] = React.useState(false);
40+
const [isSendingMessage, setIsSendingMessage] = React.useState(false);
41+
42+
const isAgentAvailable = participants.some((p) => p.isAgent);
43+
const isInputDisabled = !chatOpen || !isAgentAvailable || isSendingMessage;
44+
1045
const {
1146
micTrackRef,
12-
// FIXME: how do I explicitly ensure only the microphone channel is used?
1347
visibleControls,
48+
cameraToggle,
1449
microphoneToggle,
50+
screenShareToggle,
1551
handleAudioDeviceChange,
52+
handleVideoDeviceChange,
1653
} = useAgentControlBar({
17-
controls: { microphone: true },
18-
saveUserChoices: true,
54+
controls,
55+
saveUserChoices,
1956
});
2057

58+
const handleSendMessage = async (message: string) => {
59+
setIsSendingMessage(true);
60+
try {
61+
await onSendMessage?.(message);
62+
} finally {
63+
setIsSendingMessage(false);
64+
}
65+
};
66+
67+
React.useEffect(() => {
68+
onChatOpenChange?.(chatOpen);
69+
}, [chatOpen, onChatOpenChange]);
70+
71+
const onMicrophoneDeviceSelectError = useCallback(
72+
(error: Error) => {
73+
onDeviceError?.({ source: Track.Source.Microphone, error });
74+
},
75+
[onDeviceError]
76+
);
77+
const onCameraDeviceSelectError = useCallback(
78+
(error: Error) => {
79+
onDeviceError?.({ source: Track.Source.Camera, error });
80+
},
81+
[onDeviceError]
82+
);
83+
2184
return (
2285
<div
2386
aria-label="Voice assistant controls"
24-
className="bg-bg1 border-separator1 relative z-20 mx-1 flex h-12 shrink-0 grow-0 items-center gap-1 rounded-full border px-1 drop-shadow-md"
87+
className={cn(
88+
'bg-background border-separator1 dark:border-separator1 relative z-20 mx-2 mb-1 flex flex-col rounded-[24px] border p-1 drop-shadow-md',
89+
className
90+
)}
91+
{...props}
2592
>
26-
<div className="flex gap-1">
27-
{visibleControls.microphone && (
28-
<MicrophoneToggle
29-
micTrackRef={micTrackRef}
30-
microphoneToggle={microphoneToggle}
31-
handleAudioDeviceChange={handleAudioDeviceChange}
32-
/>
33-
)}
34-
{/* FIXME: do I need to handle the other channels here? */}
35-
</div>
93+
{capabilities.supportsChatInput && (
94+
<div
95+
inert={!chatOpen}
96+
className={cn(
97+
'relative overflow-hidden transition-[height] duration-300 ease-out',
98+
chatOpen ? 'h-[46px]' : 'h-0'
99+
)}
100+
>
101+
<div
102+
className={cn(
103+
'absolute inset-x-0 top-0 flex h-9 w-full transition-opacity duration-150 ease-linear',
104+
chatOpen ? 'opacity-100 delay-150' : 'opacity-0'
105+
)}
106+
>
107+
<ChatInput onSend={handleSendMessage} disabled={isInputDisabled} className="w-full" />
108+
</div>
109+
<hr className="border-bg2 absolute inset-x-0 bottom-0 my-1 w-full" />
110+
</div>
111+
)}
36112

37-
<ChatInput className="w-0 shrink-1 grow-1" onSend={onSend} />
113+
<div className="flex flex-row justify-between gap-1">
114+
<div className="flex gap-1">
115+
{visibleControls.microphone && (
116+
<div className="flex items-center gap-0">
117+
<TrackToggle
118+
variant="primary"
119+
source={Track.Source.Microphone}
120+
pressed={microphoneToggle.enabled}
121+
disabled={microphoneToggle.pending}
122+
onPressedChange={microphoneToggle.toggle}
123+
className="peer/track group/track relative w-auto pr-3 pl-3 has-[+_*]:rounded-r-none has-[+_*]:border-r-0 has-[+_*]:pr-2"
124+
>
125+
<BarVisualizer
126+
barCount={3}
127+
trackRef={micTrackRef}
128+
options={{ minHeight: 5 }}
129+
className="flex h-full w-auto items-center justify-center gap-0.5"
130+
>
131+
<span
132+
className={cn([
133+
'h-full w-0.5 origin-center rounded-2xl',
134+
'group-data-[state=on]/track:bg-fg1 group-data-[state=off]/track:bg-destructive-foreground',
135+
'data-lk-muted:bg-muted',
136+
])}
137+
></span>
138+
</BarVisualizer>
139+
</TrackToggle>
140+
<DeviceSelect
141+
size="sm"
142+
kind="audioinput"
143+
requestPermissions={false}
144+
onMediaDeviceError={onMicrophoneDeviceSelectError}
145+
onActiveDeviceChange={handleAudioDeviceChange}
146+
className={cn([
147+
'pl-2',
148+
'peer-data-[state=off]/track:text-destructive-foreground',
149+
'hover:text-fg1 focus:text-fg1',
150+
'hover:peer-data-[state=off]/track:text-destructive-foreground focus:peer-data-[state=off]/track:text-destructive-foreground',
151+
'hidden rounded-l-none md:block',
152+
])}
153+
/>
154+
</div>
155+
)}
156+
157+
{capabilities.supportsVideoInput && visibleControls.camera && (
158+
<div className="flex items-center gap-0">
159+
<TrackToggle
160+
variant="primary"
161+
source={Track.Source.Camera}
162+
pressed={cameraToggle.enabled}
163+
pending={cameraToggle.pending}
164+
disabled={cameraToggle.pending}
165+
onPressedChange={cameraToggle.toggle}
166+
className="peer/track relative w-auto pr-3 pl-3 disabled:opacity-100 has-[+_*]:rounded-r-none has-[+_*]:border-r-0 has-[+_*]:pr-2"
167+
/>
168+
<DeviceSelect
169+
size="sm"
170+
kind="videoinput"
171+
requestPermissions={false}
172+
onMediaDeviceError={onCameraDeviceSelectError}
173+
onActiveDeviceChange={handleVideoDeviceChange}
174+
className={cn([
175+
'pl-2',
176+
'peer-data-[state=off]/track:text-destructive-foreground',
177+
'hover:text-fg1 focus:text-fg1',
178+
'hover:peer-data-[state=off]/track:text-destructive-foreground focus:peer-data-[state=off]/track:text-destructive-foreground',
179+
'rounded-l-none',
180+
])}
181+
/>
182+
</div>
183+
)}
184+
</div>
185+
<div className="flex gap-1">
186+
{capabilities.supportsScreenShare && visibleControls.screenShare && (
187+
<div className="flex items-center gap-0">
188+
<TrackToggle
189+
variant="secondary"
190+
source={Track.Source.ScreenShare}
191+
pressed={screenShareToggle.enabled}
192+
disabled={screenShareToggle.pending}
193+
onPressedChange={screenShareToggle.toggle}
194+
className="relative w-auto"
195+
/>
196+
</div>
197+
)}
198+
199+
{visibleControls.chat && (
200+
<Toggle
201+
variant="secondary"
202+
aria-label="Toggle chat"
203+
pressed={chatOpen}
204+
onPressedChange={setChatOpen}
205+
disabled={!isAgentAvailable}
206+
className="aspect-square h-full"
207+
>
208+
<ChatTextIcon weight="bold" />
209+
</Toggle>
210+
)}
211+
</div>
212+
</div>
38213
</div>
39214
);
40215
}

components/embed-popup/agent-client.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ function AgentClient({ appConfig }: EmbedFixedAgentClientProps) {
2121
const room = useMemo(() => new Room(), []);
2222
const [popupOpen, setPopupOpen] = useState(false);
2323
const [error, setError] = useState<EmbedErrorDetails | null>(null);
24-
const { connectionDetails, refreshConnectionDetails } = useConnectionDetails();
24+
const { connectionDetails, refreshConnectionDetails, existingOrRefreshConnectionDetails } =
25+
useConnectionDetails(appConfig);
2526

2627
const handleTogglePopup = () => {
2728
if (isAnimating.current) {
@@ -79,24 +80,32 @@ function AgentClient({ appConfig }: EmbedFixedAgentClientProps) {
7980
}
8081

8182
const connect = async () => {
82-
try {
83-
await room.connect(connectionDetails.serverUrl, connectionDetails.participantToken);
84-
await room.localParticipant.setMicrophoneEnabled(true, undefined, {
83+
Promise.all([
84+
room.localParticipant.setMicrophoneEnabled(true, undefined, {
8585
preConnectBuffer: appConfig.isPreConnectBufferEnabled,
86-
});
87-
} catch (error: unknown) {
86+
}),
87+
existingOrRefreshConnectionDetails().then((connectionDetails) =>
88+
room.connect(connectionDetails.serverUrl, connectionDetails.participantToken)
89+
),
90+
]).catch((error) => {
8891
if (error instanceof Error) {
8992
console.error('Error connecting to agent:', error);
9093
setError({
9194
title: 'There was an error connecting to the agent',
9295
description: `${error.name}: ${error.message}`,
9396
});
9497
}
95-
}
98+
});
9699
};
97100

98101
connect();
99-
}, [room, popupOpen, connectionDetails, appConfig.isPreConnectBufferEnabled]);
102+
}, [
103+
room,
104+
popupOpen,
105+
connectionDetails,
106+
existingOrRefreshConnectionDetails,
107+
appConfig.isPreConnectBufferEnabled,
108+
]);
100109

101110
return (
102111
<RoomContext.Provider value={room}>
@@ -129,6 +138,7 @@ function AgentClient({ appConfig }: EmbedFixedAgentClientProps) {
129138
<ErrorMessage error={error} />
130139
{!error && (
131140
<PopupViewMotion
141+
appConfig={appConfig}
132142
initial={{ opacity: 1 }}
133143
animate={{ opacity: error === null ? 1 : 0 }}
134144
transition={{

0 commit comments

Comments
 (0)