Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 52 additions & 38 deletions README.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
},
"devDependencies": {
"@playwright/test": "^1.53.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^24.0.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
Expand Down Expand Up @@ -102,6 +103,7 @@
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@phosphor-icons/react": "^2.1.10",
"js-cookie": "^3.0.5",
"react-colorful": "^5.6.1",
"react-markdown": "^10.1.0"
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/VapiWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
voiceShowTranscript,
showTranscript = false, // deprecated
voiceAutoReconnect = false,
voiceReconnectStorage = 'session',
reconnectStorageKey = 'vapi_widget_web_call',
// Consent configuration
consentRequired,
Expand Down Expand Up @@ -150,6 +151,7 @@ const VapiWidget: React.FC<VapiWidgetProps> = ({
apiUrl,
firstChatMessage: effectiveChatFirstMessage,
voiceAutoReconnect,
voiceReconnectStorage,
reconnectStorageKey,
onCallStart: effectiveOnVoiceStart,
onCallEnd: effectiveOnVoiceEnd,
Expand Down
1 change: 1 addition & 0 deletions src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface VapiWidgetProps {
// Voice Configuration
voiceShowTranscript?: boolean;
voiceAutoReconnect?: boolean;
voiceReconnectStorage?: 'session' | 'cookies';
reconnectStorageKey?: string;

// Consent Configuration
Expand Down
47 changes: 37 additions & 10 deletions src/hooks/useVapiCall.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Vapi from '@vapi-ai/web';
import * as vapiCallStorage from '../utils/vapiCallStorage';
import type { StorageType } from '../utils/vapiCallStorage';

export interface VapiCallState {
isCallActive: boolean;
Expand All @@ -25,6 +26,7 @@ export interface UseVapiCallOptions {
apiUrl?: string;
enabled?: boolean;
voiceAutoReconnect?: boolean;
voiceReconnectStorage?: StorageType;
reconnectStorageKey?: string;
onCallStart?: () => void;
onCallEnd?: () => void;
Expand All @@ -43,6 +45,7 @@ export const useVapiCall = ({
apiUrl,
enabled = true,
voiceAutoReconnect = false,
voiceReconnectStorage = 'session',
reconnectStorageKey = 'vapi_widget_web_call',
onCallStart,
onCallEnd,
Expand Down Expand Up @@ -98,7 +101,10 @@ export const useVapiCall = ({
setIsSpeaking(false);
setIsMuted(false);
// Clear stored call data on successful call end
vapiCallStorage.clearStoredCall(reconnectStorageKey);
vapiCallStorage.clearStoredCall(
reconnectStorageKey,
voiceReconnectStorage
);
callbacksRef.current.onCallEnd?.();
};

Expand Down Expand Up @@ -153,7 +159,7 @@ export const useVapiCall = ({
vapi.removeListener('message', handleMessage);
vapi.removeListener('error', handleError);
};
}, [vapi, reconnectStorageKey]);
}, [vapi, reconnectStorageKey, voiceReconnectStorage]);

useEffect(() => {
return () => {
Expand Down Expand Up @@ -194,14 +200,26 @@ export const useVapiCall = ({

// Store call data for reconnection if call was successful and auto-reconnect is enabled
if (call && voiceAutoReconnect) {
vapiCallStorage.storeCallData(reconnectStorageKey, call, callOptions);
vapiCallStorage.storeCallData(
reconnectStorageKey,
call,
callOptions,
voiceReconnectStorage
);
}
} catch (error) {
console.error('Error starting call:', error);
setConnectionStatus('disconnected');
callbacksRef.current.onError?.(error as Error);
}
}, [vapi, callOptions, enabled, voiceAutoReconnect, reconnectStorageKey]);
}, [
vapi,
callOptions,
enabled,
voiceAutoReconnect,
voiceReconnectStorage,
reconnectStorageKey,
]);

const endCall = useCallback(
async ({ force = false }: { force?: boolean } = {}) => {
Expand Down Expand Up @@ -250,7 +268,10 @@ export const useVapiCall = ({
return;
}

const storedData = vapiCallStorage.getStoredCallData(reconnectStorageKey);
const storedData = vapiCallStorage.getStoredCallData(
reconnectStorageKey,
voiceReconnectStorage
);

if (!storedData) {
console.warn('No stored call data found for reconnection');
Expand All @@ -264,7 +285,10 @@ export const useVapiCall = ({
console.warn(
'CallOptions have changed since last call, clearing stored data and skipping reconnection'
);
vapiCallStorage.clearStoredCall(reconnectStorageKey);
vapiCallStorage.clearStoredCall(
reconnectStorageKey,
voiceReconnectStorage
);
return;
}

Expand All @@ -281,14 +305,17 @@ export const useVapiCall = ({
} catch (error) {
setConnectionStatus('disconnected');
console.error('Reconnection failed:', error);
vapiCallStorage.clearStoredCall(reconnectStorageKey);
vapiCallStorage.clearStoredCall(
reconnectStorageKey,
voiceReconnectStorage
);
callbacksRef.current.onError?.(error as Error);
}
}, [vapi, enabled, reconnectStorageKey, callOptions]);
}, [vapi, enabled, reconnectStorageKey, voiceReconnectStorage, callOptions]);

const clearStoredCall = useCallback(() => {
vapiCallStorage.clearStoredCall(reconnectStorageKey);
}, [reconnectStorageKey]);
vapiCallStorage.clearStoredCall(reconnectStorageKey, voiceReconnectStorage);
}, [reconnectStorageKey, voiceReconnectStorage]);

useEffect(() => {
if (!vapi || !enabled || !voiceAutoReconnect) {
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useVapiWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface UseVapiWidgetOptions {
apiUrl?: string;
firstChatMessage?: string;
voiceAutoReconnect?: boolean;
voiceReconnectStorage?: 'session' | 'cookies';
reconnectStorageKey?: string;
onCallStart?: () => void;
onCallEnd?: () => void;
Expand All @@ -32,6 +33,7 @@ export const useVapiWidget = ({
apiUrl,
firstChatMessage,
voiceAutoReconnect = false,
voiceReconnectStorage = 'session',
reconnectStorageKey,
onCallStart,
onCallEnd,
Expand Down Expand Up @@ -68,6 +70,7 @@ export const useVapiWidget = ({
apiUrl,
enabled: voiceEnabled,
voiceAutoReconnect,
voiceReconnectStorage,
reconnectStorageKey,
onCallStart: () => {
// In hybrid mode, clear all conversations when starting voice
Expand Down
136 changes: 115 additions & 21 deletions src/utils/vapiCallStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Cookies from 'js-cookie';

export interface StoredCallData {
webCallUrl: string;
id?: string;
Expand All @@ -11,50 +13,142 @@ export interface StoredCallData {
};
callOptions?: any;
timestamp: number;
tabId?: string;
}

export type StorageType = 'session' | 'cookies';

// Generate or retrieve tab-specific ID (stored in sessionStorage)
function getTabId(): string {
const TAB_ID_KEY = '_vapi_tab_id';
let tabId = sessionStorage.getItem(TAB_ID_KEY);

if (!tabId) {
tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem(TAB_ID_KEY, tabId);
}

return tabId;
}

// Get root domain for cookie (e.g., ".domain.com")
function getRootDomain(): string {
const hostname = window.location.hostname;

// Handle localhost/IP
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
/^\d+\.\d+\.\d+\.\d+$/.test(hostname)
) {
return hostname;
}

const parts = hostname.split('.');

// Already root domain
if (parts.length <= 2) {
return hostname;
}

// Return root domain with leading dot (e.g., ".domain.com")
return '.' + parts.slice(-2).join('.');
}

export const storeCallData = (
reconnectStorageKey: string,
call: any,
callOptions?: any
callOptions?: any,
storageType: StorageType = 'session'
) => {
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 {
if (!webCallUrl) {
console.warn(
'No webCallUrl found in call object, cannot store for reconnection'
);
return;
}

const webCallToStore: StoredCallData = {
webCallUrl,
id: call.id,
artifactPlan: call.artifactPlan,
assistant: call.assistant,
callOptions,
timestamp: Date.now(),
};

if (storageType === 'session') {
sessionStorage.setItem(reconnectStorageKey, JSON.stringify(webCallToStore));
} else if (storageType === 'cookies') {
const tabId = getTabId();
const webCallToStoreWithTab = { ...webCallToStore, tabId };

try {
const rootDomain = getRootDomain();

Cookies.set(reconnectStorageKey, JSON.stringify(webCallToStoreWithTab), {
domain: rootDomain,
path: '/',
secure: true,
sameSite: 'lax',
expires: 1 / 24, // 1 hour (expires takes days, so 1/24 = 1 hour)
});
} catch (error) {
console.error('Failed to store call data in cookie:', error);
}
}
};

export const getStoredCallData = (
reconnectStorageKey: string
reconnectStorageKey: string,
storageType: StorageType = 'session'
): StoredCallData | null => {
try {
const stored = sessionStorage.getItem(reconnectStorageKey);
if (!stored) return null;
if (storageType === 'session') {
const sessionData = sessionStorage.getItem(reconnectStorageKey);
if (!sessionData) return null;

return JSON.parse(stored);
} catch {
sessionStorage.removeItem(reconnectStorageKey);
return JSON.parse(sessionData);
} else if (storageType === 'cookies') {
const currentTabId = getTabId();
const cookieValue = Cookies.get(reconnectStorageKey);

if (!cookieValue) return null;

const data = JSON.parse(cookieValue);

// Verify tab ID matches (prevents multi-tab reconnection)
if (data.tabId !== currentTabId) {
console.warn('Tab ID mismatch - ignoring call data from different tab');
return null;
}

return data;
}

return null;
} catch (error) {
console.error('Error reading stored call data:', error);
return null;
}
};

export const clearStoredCall = (reconnectStorageKey: string) => {
sessionStorage.removeItem(reconnectStorageKey);
export const clearStoredCall = (
reconnectStorageKey: string,
storageType: StorageType = 'session'
) => {
if (storageType === 'session') {
sessionStorage.removeItem(reconnectStorageKey);
} else if (storageType === 'cookies') {
const rootDomain = getRootDomain();
Cookies.remove(reconnectStorageKey, {
domain: rootDomain,
path: '/',
});
}
};

export const areCallOptionsEqual = (options1: any, options2: any): boolean => {
Expand Down
Loading
Loading