diff --git a/package-lock.json b/package-lock.json index 38f528c..66183df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "vite-plugin-dts": "^3.9.1" }, "peerDependencies": { - "@vapi-ai/web": "^2.3.7", + "@vapi-ai/web": "^2.5.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" } @@ -318,9 +318,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "peer": true, "engines": { @@ -376,9 +376,9 @@ } }, "node_modules/@daily-co/daily-js": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.80.0.tgz", - "integrity": "sha512-zG2NBbKbHfm56P0lg4ddC94vBtn5AQKcgvbYrO5+ohNWPSolMqlJiYxQC9uhOHfFYRhH4ELKQ6NHqGatX9VD7A==", + "version": "0.84.0", + "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.84.0.tgz", + "integrity": "sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -2173,13 +2173,13 @@ "license": "ISC" }, "node_modules/@vapi-ai/web": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/@vapi-ai/web/-/web-2.3.7.tgz", - "integrity": "sha512-2wG8j2h2FwOiNsVJHXlUKkq828Pj6uQn3UTJSWCjpqiA4g7GSmi/Qb/+hxWjVBnCdiE+t05PsOAM6cUpVY+3zQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@vapi-ai/web/-/web-2.5.0.tgz", + "integrity": "sha512-I+XkLPG99kWspJ2AH4vky/vSgCui9ntb20n4/D9RSP6ChG3RHNvZVXoA79gKiY1fWmowuzBD7hL8sJbGHfJOJw==", "license": "MIT", "peer": true, "dependencies": { - "@daily-co/daily-js": "^0.80.0", + "@daily-co/daily-js": "^0.84.0", "events": "^3.3.0" }, "engines": { @@ -2550,9 +2550,9 @@ } }, "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", "license": "MIT", "peer": true }, diff --git a/package.json b/package.json index 8b9af70..c41234a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ ], "license": "", "peerDependencies": { - "@vapi-ai/web": "^2.3.7", + "@vapi-ai/web": "^2.5.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" }, diff --git a/src/components/VapiWidget.tsx b/src/components/VapiWidget.tsx index e984b9d..06cc9e9 100644 --- a/src/components/VapiWidget.tsx +++ b/src/components/VapiWidget.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useVapiWidget } from '../hooks'; import { VapiWidgetProps, ColorScheme, StyleConfig } from './types'; @@ -61,6 +61,8 @@ const VapiWidget: React.FC = ({ // Voice configuration voiceShowTranscript, showTranscript = false, // deprecated + voiceAutoReconnect = false, + reconnectStorageKey = 'vapi_widget_web_call', // Consent configuration consentRequired, requireConsent = false, // deprecated @@ -77,7 +79,19 @@ const VapiWidget: React.FC = ({ onMessage, onError, }) => { - const [isExpanded, setIsExpanded] = useState(false); + // Create storage key for expanded state + const expandedStorageKey = `vapi_widget_expanded`; + + // Initialize expanded state from localStorage + const [isExpanded, setIsExpanded] = useState(() => { + try { + const stored = sessionStorage.getItem(expandedStorageKey); + return stored === 'true'; + } catch { + return false; + } + }); + const [hasConsent, setHasConsent] = useState(false); const [chatInput, setChatInput] = useState(''); const [showEndScreen, setShowEndScreen] = useState(false); @@ -85,6 +99,19 @@ const VapiWidget: React.FC = ({ const conversationEndRef = useRef(null); const inputRef = useRef(null); + // Custom setter that updates both state and localStorage + const updateExpandedState = useCallback( + (expanded: boolean) => { + setIsExpanded(expanded); + try { + sessionStorage.setItem(expandedStorageKey, expanded.toString()); + } catch (error) { + console.warn('Failed to save expanded state to localStorage:', error); + } + }, + [expandedStorageKey] + ); + const effectiveBorderRadius = borderRadius ?? radius; const effectiveBaseBgColor = baseBgColor ?? baseColor; const effectiveAccentColor = accentColor ?? '#14B8A6'; @@ -122,6 +149,8 @@ const VapiWidget: React.FC = ({ assistantOverrides, apiUrl, firstChatMessage: effectiveChatFirstMessage, + voiceAutoReconnect, + reconnectStorageKey, onCallStart: effectiveOnVoiceStart, onCallEnd: effectiveOnVoiceEnd, onMessage, @@ -232,11 +261,11 @@ const VapiWidget: React.FC = ({ }; const handleConsentCancel = () => { - setIsExpanded(false); + updateExpandedState(false); }; const handleToggleCall = async () => { - await vapi.voice.toggleCall(); + await vapi.voice.toggleCall({ force: voiceAutoReconnect }); }; const handleSendMessage = async () => { @@ -260,7 +289,7 @@ const VapiWidget: React.FC = ({ setShowEndScreen(false); if (vapi.voice.isCallActive) { - vapi.voice.endCall(); + vapi.voice.endCall({ force: voiceAutoReconnect }); } setChatInput(''); @@ -297,11 +326,11 @@ const VapiWidget: React.FC = ({ setShowEndScreen(false); setChatInput(''); } - setIsExpanded(false); + updateExpandedState(false); }; const handleFloatingButtonClick = () => { - setIsExpanded(true); + updateExpandedState(true); }; const renderConversationMessages = () => { diff --git a/src/components/types.ts b/src/components/types.ts index 63aec12..6931d8a 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -48,6 +48,8 @@ export interface VapiWidgetProps { // Voice Configuration voiceShowTranscript?: boolean; + voiceAutoReconnect?: boolean; + reconnectStorageKey?: string; // Consent Configuration consentRequired?: boolean; diff --git a/src/hooks/useVapiCall.ts b/src/hooks/useVapiCall.ts index 8627185..90f676f 100644 --- a/src/hooks/useVapiCall.ts +++ b/src/hooks/useVapiCall.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import Vapi from '@vapi-ai/web'; +import * as vapiCallStorage from '../utils/vapiCallStorage'; export interface VapiCallState { isCallActive: boolean; @@ -11,9 +12,11 @@ export interface VapiCallState { export interface VapiCallHandlers { startCall: () => Promise; - endCall: () => Promise; - toggleCall: () => Promise; + endCall: (opts?: { force?: boolean }) => Promise; + toggleCall: (opts?: { force?: boolean }) => Promise; toggleMute: () => void; + reconnect: () => Promise; + clearStoredCall: () => void; } export interface UseVapiCallOptions { @@ -21,6 +24,8 @@ export interface UseVapiCallOptions { callOptions: any; apiUrl?: string; enabled?: boolean; + voiceAutoReconnect?: boolean; + reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; onMessage?: (message: any) => void; @@ -37,6 +42,8 @@ export const useVapiCall = ({ callOptions, apiUrl, enabled = true, + voiceAutoReconnect = false, + reconnectStorageKey = 'vapi_widget_web_call', onCallStart, onCallEnd, onMessage, @@ -90,6 +97,8 @@ export const useVapiCall = ({ setVolumeLevel(0); setIsSpeaking(false); setIsMuted(false); + // Clear stored call data on successful call end + vapiCallStorage.clearStoredCall(reconnectStorageKey); callbacksRef.current.onCallEnd?.(); }; @@ -144,7 +153,7 @@ export const useVapiCall = ({ vapi.removeListener('message', handleMessage); vapi.removeListener('error', handleError); }; - }, [vapi]); + }, [vapi, reconnectStorageKey]); useEffect(() => { return () => { @@ -161,33 +170,68 @@ export const useVapiCall = ({ } try { - console.log('Starting call with options:', callOptions); + console.log('Starting call with configuration:', callOptions); + console.log('Starting call with options:', { + voiceAutoReconnect, + }); setConnectionStatus('connecting'); - await vapi.start(callOptions); + const call = await vapi.start( + // assistant + callOptions, + // assistant overrides, + undefined, + // squad + undefined, + // workflow + undefined, + // workflow overrides + undefined, + // options + { + roomDeleteOnUserLeaveEnabled: !voiceAutoReconnect, + } + ); + + // Store call data for reconnection if call was successful and auto-reconnect is enabled + if (call && voiceAutoReconnect) { + vapiCallStorage.storeCallData(reconnectStorageKey, call, callOptions); + } } catch (error) { console.error('Error starting call:', error); setConnectionStatus('disconnected'); callbacksRef.current.onError?.(error as Error); } - }, [vapi, callOptions, enabled]); + }, [vapi, callOptions, enabled, voiceAutoReconnect, reconnectStorageKey]); - const endCall = useCallback(async () => { - if (!vapi) { - console.log('Cannot end call: no vapi instance'); - return; - } + const endCall = useCallback( + async ({ force = false }: { force?: boolean } = {}) => { + if (!vapi) { + console.log('Cannot end call: no vapi instance'); + return; + } - console.log('Ending call'); - vapi.stop(); - }, [vapi]); + console.log('Ending call with force:', force); + if (force) { + // end vapi call and delete daily room + vapi.end(); + } else { + // simply disconnect from daily room + vapi.stop(); + } + }, + [vapi] + ); - const toggleCall = useCallback(async () => { - if (isCallActive) { - await endCall(); - } else { - await startCall(); - } - }, [isCallActive, startCall, endCall]); + const toggleCall = useCallback( + async ({ force = false }: { force?: boolean } = {}) => { + if (isCallActive) { + await endCall({ force }); + } else { + await startCall(); + } + }, + [isCallActive, startCall, endCall] + ); const toggleMute = useCallback(() => { if (!vapi || !isCallActive) { @@ -200,6 +244,59 @@ export const useVapiCall = ({ setIsMuted(newMutedState); }, [vapi, isCallActive, isMuted]); + const reconnect = useCallback(async () => { + if (!vapi || !enabled) { + console.error('Cannot reconnect: no vapi instance or not enabled'); + return; + } + + const storedData = vapiCallStorage.getStoredCallData(reconnectStorageKey); + + if (!storedData) { + console.warn('No stored call data found for reconnection'); + return; + } + + // Check if callOptions match before reconnecting + if ( + !vapiCallStorage.areCallOptionsEqual(storedData.callOptions, callOptions) + ) { + console.warn( + 'CallOptions have changed since last call, clearing stored data and skipping reconnection' + ); + vapiCallStorage.clearStoredCall(reconnectStorageKey); + return; + } + + setConnectionStatus('connecting'); + + try { + await vapi.reconnect({ + webCallUrl: storedData.webCallUrl, + id: storedData.id, + artifactPlan: storedData.artifactPlan, + assistant: storedData.assistant, + }); + console.log('Successfully reconnected to call'); + } catch (error) { + setConnectionStatus('disconnected'); + console.error('Reconnection failed:', error); + vapiCallStorage.clearStoredCall(reconnectStorageKey); + callbacksRef.current.onError?.(error as Error); + } + }, [vapi, enabled, reconnectStorageKey, callOptions]); + + const clearStoredCall = useCallback(() => { + vapiCallStorage.clearStoredCall(reconnectStorageKey); + }, [reconnectStorageKey]); + + useEffect(() => { + if (!vapi || !enabled || !voiceAutoReconnect) { + return; + } + reconnect(); + }, [vapi, enabled, voiceAutoReconnect, reconnect, reconnectStorageKey]); + return { // State isCallActive, @@ -212,5 +309,7 @@ export const useVapiCall = ({ endCall, toggleCall, toggleMute, + reconnect, + clearStoredCall, }; }; diff --git a/src/hooks/useVapiWidget.ts b/src/hooks/useVapiWidget.ts index 17fb76c..46bb4f2 100644 --- a/src/hooks/useVapiWidget.ts +++ b/src/hooks/useVapiWidget.ts @@ -15,6 +15,8 @@ export interface UseVapiWidgetOptions { assistantOverrides?: AssistantOverrides; apiUrl?: string; firstChatMessage?: string; + voiceAutoReconnect?: boolean; + reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; onMessage?: (message: any) => void; @@ -29,6 +31,8 @@ export const useVapiWidget = ({ assistantOverrides, apiUrl, firstChatMessage, + voiceAutoReconnect = false, + reconnectStorageKey, onCallStart, onCallEnd, onMessage, @@ -63,6 +67,8 @@ export const useVapiWidget = ({ callOptions: buildCallOptions(), apiUrl, enabled: voiceEnabled, + voiceAutoReconnect, + reconnectStorageKey, onCallStart: () => { // In hybrid mode, clear all conversations when starting voice if (mode === 'hybrid') { @@ -123,7 +129,7 @@ export const useVapiWidget = ({ // In hybrid mode, switch to chat and clear all conversations only if switching from voice if (mode === 'hybrid') { if (voice.isCallActive) { - await voice.endCall(); + await voice.endCall({ force: true }); } // Only clear conversations if we're switching from voice mode if (activeMode !== 'chat') { @@ -137,16 +143,19 @@ export const useVapiWidget = ({ [mode, chat, voice, activeMode] ); - const toggleCall = useCallback(async () => { - if (mode === 'hybrid' && !voice.isCallActive) { - // Clear all conversations when switching to voice - chat.clearMessages(); - setVoiceConversation([]); - setActiveMode('voice'); - setIsUserTyping(false); - } - await voice.toggleCall(); - }, [mode, voice, chat]); + const toggleCall = useCallback( + async ({ force }: { force?: boolean } = {}) => { + if (mode === 'hybrid' && !voice.isCallActive) { + // Clear all conversations when switching to voice + chat.clearMessages(); + setVoiceConversation([]); + setActiveMode('voice'); + setIsUserTyping(false); + } + await voice.toggleCall({ force }); + }, + [mode, voice, chat] + ); const clearConversation = useCallback(() => { setVoiceConversation([]); diff --git a/src/utils/index.ts b/src/utils/index.ts index 6ec8afc..23d1ffd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './vapiChatClient'; +export * from './vapiCallStorage'; diff --git a/src/utils/vapiCallStorage.ts b/src/utils/vapiCallStorage.ts new file mode 100644 index 0000000..aff90e4 --- /dev/null +++ b/src/utils/vapiCallStorage.ts @@ -0,0 +1,73 @@ +export interface StoredCallData { + webCallUrl: string; + id?: string; + artifactPlan?: { + videoRecordingEnabled?: boolean; + }; + assistant?: { + voice?: { + provider?: string; + }; + }; + callOptions?: any; + timestamp: number; +} + +export const storeCallData = ( + reconnectStorageKey: string, + call: any, + callOptions?: any +) => { + const webCallUrl = + (call as any).webCallUrl || (call.transport as any)?.callUrl; + + if (webCallUrl) { + const webCallToStore = { + webCallUrl, + id: call.id, + artifactPlan: call.artifactPlan, + assistant: call.assistant, + callOptions, + timestamp: Date.now(), + }; + sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore)); + console.log('Stored call data for reconnection:', webCallToStore); + } else { + console.warn( + 'No webCallUrl found in call object, cannot store for reconnection' + ); + } +}; + +export const getStoredCallData = ( + reconnectStorageKey: string +): StoredCallData | null => { + try { + const stored = sessionStorage.getItem(reconnectStorageKey); + if (!stored) return null; + + return JSON.parse(stored); + } catch { + sessionStorage.removeItem(reconnectStorageKey); + return null; + } +}; + +export const clearStoredCall = (reconnectStorageKey: string) => { + sessionStorage.removeItem(reconnectStorageKey); +}; + +export const areCallOptionsEqual = (options1: any, options2: any): boolean => { + // Handle null/undefined cases + if (options1 === options2) return true; + if (!options1 || !options2) return false; + + try { + // Deep comparison using JSON serialization + // This works for most cases but may not handle functions, dates, etc. + return JSON.stringify(options1) === JSON.stringify(options2); + } catch { + // Fallback to reference equality if JSON.stringify fails + return options1 === options2; + } +}; diff --git a/src/widget/index.ts b/src/widget/index.ts index ebdb4f7..f6a6a26 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -168,6 +168,8 @@ function initializeWidgets() { // Voice Configuration 'voice-show-transcript': 'voiceShowTranscript', + 'voice-auto-reconnect': 'voiceAutoReconnect', + 'reconnect-storage-key': 'reconnectStorageKey', // Consent Configuration 'consent-required': 'consentRequired',