Skip to content

Commit dddf82c

Browse files
committed
feat: support voice reconnect
1 parent 1f60dc9 commit dddf82c

File tree

4 files changed

+233
-39
lines changed

4 files changed

+233
-39
lines changed

src/components/VapiWidget.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useRef } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
22
import { useVapiWidget } from '../hooks';
33

44
import { VapiWidgetProps, ColorScheme, StyleConfig } from './types';
@@ -61,6 +61,8 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
6161
// Voice configuration
6262
voiceShowTranscript,
6363
showTranscript = false, // deprecated
64+
voiceAutoReconnect = false,
65+
reconnectStorageKey = 'vapi_widget_web_call',
6466
// Consent configuration
6567
consentRequired,
6668
requireConsent = false, // deprecated
@@ -77,14 +79,39 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
7779
onMessage,
7880
onError,
7981
}) => {
80-
const [isExpanded, setIsExpanded] = useState(false);
82+
// Create storage key for expanded state
83+
const expandedStorageKey = `vapi_widget_expanded`;
84+
85+
// Initialize expanded state from localStorage
86+
const [isExpanded, setIsExpanded] = useState(() => {
87+
try {
88+
const stored = localStorage.getItem(expandedStorageKey);
89+
return stored === 'true';
90+
} catch {
91+
return false;
92+
}
93+
});
94+
8195
const [hasConsent, setHasConsent] = useState(false);
8296
const [chatInput, setChatInput] = useState('');
8397
const [showEndScreen, setShowEndScreen] = useState(false);
8498

8599
const conversationEndRef = useRef<HTMLDivElement>(null);
86100
const inputRef = useRef<HTMLInputElement>(null);
87101

102+
// Custom setter that updates both state and localStorage
103+
const updateExpandedState = useCallback(
104+
(expanded: boolean) => {
105+
setIsExpanded(expanded);
106+
try {
107+
localStorage.setItem(expandedStorageKey, expanded.toString());
108+
} catch (error) {
109+
console.warn('Failed to save expanded state to localStorage:', error);
110+
}
111+
},
112+
[expandedStorageKey]
113+
);
114+
88115
const effectiveBorderRadius = borderRadius ?? radius;
89116
const effectiveBaseBgColor = baseBgColor ?? baseColor;
90117
const effectiveAccentColor = accentColor ?? '#14B8A6';
@@ -122,6 +149,8 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
122149
assistantOverrides,
123150
apiUrl,
124151
firstChatMessage: effectiveChatFirstMessage,
152+
voiceAutoReconnect,
153+
reconnectStorageKey,
125154
onCallStart: effectiveOnVoiceStart,
126155
onCallEnd: effectiveOnVoiceEnd,
127156
onMessage,
@@ -232,11 +261,11 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
232261
};
233262

234263
const handleConsentCancel = () => {
235-
setIsExpanded(false);
264+
updateExpandedState(false);
236265
};
237266

238267
const handleToggleCall = async () => {
239-
await vapi.voice.toggleCall();
268+
await vapi.voice.toggleCall({ force: voiceAutoReconnect });
240269
};
241270

242271
const handleSendMessage = async () => {
@@ -260,7 +289,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
260289
setShowEndScreen(false);
261290

262291
if (vapi.voice.isCallActive) {
263-
vapi.voice.endCall();
292+
vapi.voice.endCall({ force: voiceAutoReconnect });
264293
}
265294

266295
setChatInput('');
@@ -297,11 +326,11 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
297326
setShowEndScreen(false);
298327
setChatInput('');
299328
}
300-
setIsExpanded(false);
329+
updateExpandedState(false);
301330
};
302331

303332
const handleFloatingButtonClick = () => {
304-
setIsExpanded(true);
333+
updateExpandedState(true);
305334
};
306335

307336
const renderConversationMessages = () => {

src/components/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface VapiWidgetProps {
4848

4949
// Voice Configuration
5050
voiceShowTranscript?: boolean;
51+
voiceAutoReconnect?: boolean;
52+
reconnectStorageKey?: string;
5153

5254
// Consent Configuration
5355
consentRequired?: boolean;

src/hooks/useVapiCall.ts

Lines changed: 175 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
import { useState, useEffect, useRef, useCallback } from 'react';
22
import Vapi from '@vapi-ai/web';
33

4+
interface StoredCallData {
5+
webCallUrl: string;
6+
id?: string;
7+
artifactPlan?: {
8+
videoRecordingEnabled?: boolean;
9+
};
10+
assistant?: {
11+
voice?: {
12+
provider?: string;
13+
};
14+
};
15+
timestamp: number;
16+
}
17+
418
export interface VapiCallState {
519
isCallActive: boolean;
620
isSpeaking: boolean;
@@ -11,16 +25,20 @@ export interface VapiCallState {
1125

1226
export interface VapiCallHandlers {
1327
startCall: () => Promise<void>;
14-
endCall: () => Promise<void>;
15-
toggleCall: () => Promise<void>;
28+
endCall: (opts?: { force?: boolean }) => Promise<void>;
29+
toggleCall: (opts?: { force?: boolean }) => Promise<void>;
1630
toggleMute: () => void;
31+
reconnect: () => Promise<void>;
32+
clearStoredCall: () => void;
1733
}
1834

1935
export interface UseVapiCallOptions {
2036
publicKey: string;
2137
callOptions: any;
2238
apiUrl?: string;
2339
enabled?: boolean;
40+
roomDeleteOnUserLeaveEnabled?: boolean;
41+
reconnectStorageKey?: string;
2442
onCallStart?: () => void;
2543
onCallEnd?: () => void;
2644
onMessage?: (message: any) => void;
@@ -37,6 +55,8 @@ export const useVapiCall = ({
3755
callOptions,
3856
apiUrl,
3957
enabled = true,
58+
roomDeleteOnUserLeaveEnabled = false,
59+
reconnectStorageKey = 'vapi_widget_web_call',
4060
onCallStart,
4161
onCallEnd,
4262
onMessage,
@@ -63,6 +83,54 @@ export const useVapiCall = ({
6383
onTranscript,
6484
});
6585

86+
// localStorage utilities
87+
const storeCallData = useCallback(
88+
(call: any) => {
89+
if (!roomDeleteOnUserLeaveEnabled) {
90+
// Extract webCallUrl from the call object
91+
// The webCallUrl can be in call.webCallUrl or call.transport.callUrl
92+
const webCallUrl =
93+
(call as any).webCallUrl || (call.transport as any)?.callUrl;
94+
95+
if (webCallUrl) {
96+
const webCallToStore = {
97+
webCallUrl,
98+
id: call.id,
99+
artifactPlan: call.artifactPlan,
100+
assistant: call.assistant,
101+
timestamp: Date.now(),
102+
};
103+
localStorage.setItem(
104+
reconnectStorageKey,
105+
JSON.stringify(webCallToStore)
106+
);
107+
console.log('Stored call data for reconnection:', webCallToStore);
108+
} else {
109+
console.warn(
110+
'No webCallUrl found in call object, cannot store for reconnection'
111+
);
112+
}
113+
}
114+
},
115+
[roomDeleteOnUserLeaveEnabled, reconnectStorageKey]
116+
);
117+
118+
const getStoredCallData = useCallback((): StoredCallData | null => {
119+
try {
120+
const stored = localStorage.getItem(reconnectStorageKey);
121+
if (!stored) return null;
122+
123+
return JSON.parse(stored);
124+
} catch {
125+
localStorage.removeItem(reconnectStorageKey);
126+
return null;
127+
}
128+
}, [reconnectStorageKey]);
129+
130+
const clearStoredCall = useCallback(() => {
131+
localStorage.removeItem(reconnectStorageKey);
132+
}, [reconnectStorageKey]);
133+
66134
useEffect(() => {
67135
callbacksRef.current = {
68136
onCallStart,
@@ -90,6 +158,8 @@ export const useVapiCall = ({
90158
setVolumeLevel(0);
91159
setIsSpeaking(false);
92160
setIsMuted(false);
161+
// Clear stored call data on successful call end
162+
clearStoredCall();
93163
callbacksRef.current.onCallEnd?.();
94164
};
95165

@@ -144,7 +214,7 @@ export const useVapiCall = ({
144214
vapi.removeListener('message', handleMessage);
145215
vapi.removeListener('error', handleError);
146216
};
147-
}, [vapi]);
217+
}, [vapi, clearStoredCall]);
148218

149219
useEffect(() => {
150220
return () => {
@@ -161,33 +231,68 @@ export const useVapiCall = ({
161231
}
162232

163233
try {
164-
console.log('Starting call with options:', callOptions);
234+
console.log('Starting call with configuration:', callOptions);
235+
console.log('Starting call with options:', {
236+
roomDeleteOnUserLeaveEnabled,
237+
});
165238
setConnectionStatus('connecting');
166-
await vapi.start(callOptions);
239+
const call = await vapi.start(
240+
// assistant
241+
callOptions,
242+
// assistant overrides,
243+
undefined,
244+
// squad
245+
undefined,
246+
// workflow
247+
undefined,
248+
// workflow overrides
249+
undefined,
250+
// options
251+
{
252+
roomDeleteOnUserLeaveEnabled,
253+
}
254+
);
255+
256+
// Store call data for reconnection if call was successful and auto-reconnect is enabled
257+
if (call && !roomDeleteOnUserLeaveEnabled) {
258+
storeCallData(call);
259+
}
167260
} catch (error) {
168261
console.error('Error starting call:', error);
169262
setConnectionStatus('disconnected');
170263
callbacksRef.current.onError?.(error as Error);
171264
}
172-
}, [vapi, callOptions, enabled]);
265+
}, [vapi, callOptions, enabled, roomDeleteOnUserLeaveEnabled, storeCallData]);
173266

174-
const endCall = useCallback(async () => {
175-
if (!vapi) {
176-
console.log('Cannot end call: no vapi instance');
177-
return;
178-
}
267+
const endCall = useCallback(
268+
async ({ force = false }: { force?: boolean } = {}) => {
269+
if (!vapi) {
270+
console.log('Cannot end call: no vapi instance');
271+
return;
272+
}
179273

180-
console.log('Ending call');
181-
vapi.stop();
182-
}, [vapi]);
274+
console.log('Ending call with force:', force);
275+
if (force) {
276+
// end vapi call and delete daily room
277+
vapi.end();
278+
} else {
279+
// simply disconnect from daily room
280+
vapi.stop();
281+
}
282+
},
283+
[vapi]
284+
);
183285

184-
const toggleCall = useCallback(async () => {
185-
if (isCallActive) {
186-
await endCall();
187-
} else {
188-
await startCall();
189-
}
190-
}, [isCallActive, startCall, endCall]);
286+
const toggleCall = useCallback(
287+
async ({ force = false }: { force?: boolean } = {}) => {
288+
if (isCallActive) {
289+
await endCall({ force });
290+
} else {
291+
await startCall();
292+
}
293+
},
294+
[isCallActive, startCall, endCall]
295+
);
191296

192297
const toggleMute = useCallback(() => {
193298
if (!vapi || !isCallActive) {
@@ -200,6 +305,53 @@ export const useVapiCall = ({
200305
setIsMuted(newMutedState);
201306
}, [vapi, isCallActive, isMuted]);
202307

308+
const reconnect = useCallback(async () => {
309+
if (!vapi || !enabled) {
310+
console.error('Cannot reconnect: no vapi instance or not enabled');
311+
return;
312+
}
313+
314+
const storedData = getStoredCallData();
315+
if (!storedData) {
316+
console.warn('No stored call data found for reconnection');
317+
return;
318+
}
319+
320+
setConnectionStatus('connecting');
321+
try {
322+
await vapi.reconnect({
323+
webCallUrl: storedData.webCallUrl,
324+
id: storedData.id,
325+
artifactPlan: storedData.artifactPlan,
326+
assistant: storedData.assistant,
327+
});
328+
console.log('Successfully reconnected to call');
329+
} catch (error) {
330+
setConnectionStatus('disconnected');
331+
console.error('Reconnection failed:', error);
332+
clearStoredCall();
333+
callbacksRef.current.onError?.(error as Error);
334+
}
335+
}, [vapi, enabled, getStoredCallData, clearStoredCall]);
336+
337+
// Check for stored call data on initialization and attempt reconnection
338+
useEffect(() => {
339+
if (!vapi || !enabled || roomDeleteOnUserLeaveEnabled) {
340+
return;
341+
}
342+
343+
const storedData = getStoredCallData();
344+
if (storedData) {
345+
reconnect();
346+
}
347+
}, [
348+
vapi,
349+
enabled,
350+
roomDeleteOnUserLeaveEnabled,
351+
getStoredCallData,
352+
reconnect,
353+
]);
354+
203355
return {
204356
// State
205357
isCallActive,
@@ -212,5 +364,7 @@ export const useVapiCall = ({
212364
endCall,
213365
toggleCall,
214366
toggleMute,
367+
reconnect,
368+
clearStoredCall,
215369
};
216370
};

0 commit comments

Comments
 (0)