Skip to content

Commit 894ecd4

Browse files
design: avatar polish
1 parent 2647b84 commit 894ecd4

26 files changed

+978
-328
lines changed

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ LIVEKIT_API_KEY=<your_api_key>
33
LIVEKIT_API_SECRET=<your_api_secret>
44
LIVEKIT_URL=wss://<project-subdomain>.livekit.cloud
55

6-
76
# Internally used environment variables
7+
NEXT_PUBLIC_CONN_DETAILS_ENDPOINT=
88
NEXT_PUBLIC_APP_CONFIG_ENDPOINT=
99
SANDBOX_ID=

app-config.ts

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

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

87
supportsChatInput: true,
98
supportsVideoInput: true,
109
supportsScreenShare: true,
1110
isPreConnectBufferEnabled: true,
1211

13-
logo: '/lk-logo.svg',
1412
accent: '#002cf2',
15-
logoDark: '/lk-logo-dark.svg',
1613
accentDark: '#1fd5f9',
17-
startButtonText: 'Start call',
1814
};

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: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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';
8+
import { ChatInput } from '@/components/livekit/chat/chat-input';
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';
15+
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;
23+
}
24+
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+
45+
const {
46+
micTrackRef,
47+
visibleControls,
48+
cameraToggle,
49+
microphoneToggle,
50+
screenShareToggle,
51+
handleAudioDeviceChange,
52+
handleVideoDeviceChange,
53+
} = useAgentControlBar({
54+
controls,
55+
saveUserChoices,
56+
});
57+
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+
84+
return (
85+
<div
86+
aria-label="Voice assistant controls"
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}
92+
>
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+
)}
112+
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>
213+
</div>
214+
);
215+
}

0 commit comments

Comments
 (0)