From bb9897c1a58703df4bcce6703bc590407495a7b5 Mon Sep 17 00:00:00 2001 From: Steven Diaz Date: Sat, 1 Nov 2025 23:48:45 -0700 Subject: [PATCH 1/2] feat: add cookie option for voice reconnect --- README.md | 18 ++++- package-lock.json | 18 +++++ package.json | 2 + src/components/VapiWidget.tsx | 2 + src/components/types.ts | 1 + src/hooks/useVapiCall.ts | 47 +++++++++--- src/hooks/useVapiWidget.ts | 3 + src/utils/vapiCallStorage.ts | 136 ++++++++++++++++++++++++++++------ src/widget/index.ts | 1 + 9 files changed, 195 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c0170a0..0d60bd8 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,9 @@ The simplest way to add the widget to your website: | `chatPlaceholder` | `string` | `'Type your message...'` | Chat input placeholder text | | **Voice Configuration** | | | | | `voiceShowTranscript` | `boolean` | `false` | Show/hide voice transcript | -| `voiceAutoReconnect` | `boolean` | `false` | Auto-reconnect to an active web call within the same browser tab (uses session storage) | -| `reconnectStorageKey` | `string` | `'vapi_widget_web_call'` | Key for storing reconnection data (uses session storage) | +| `voiceAutoReconnect` | `boolean` | `false` | Auto-reconnect to an active web call (see `voiceReconnectStorage` for scope) | +| `voiceReconnectStorage` | `'session' \| 'cookies'` | `'session'` | Storage type: 'session' (same tab only) or 'cookies' (same tab across subdomains) | +| `reconnectStorageKey` | `string` | `'vapi_widget_web_call'` | Key for storing reconnection data | | **Consent Configuration** | | | | | `consentRequired` | `boolean` | `false` | Show consent form before first use | | `consentTitle` | `string` | `"Terms and conditions"` | Consent form title | @@ -286,6 +287,19 @@ Use this approach if your environment doesn't support custom elements or for bet mode="voice" voiceAutoReconnect={true} /> +```` + +### Voice with Cross-Subdomain Reconnection + +```tsx + +``` ## Development diff --git a/package-lock.json b/package-lock.json index 5318936..57e68b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,13 @@ "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" }, "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", @@ -1916,6 +1918,13 @@ "@types/unist": "*" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4312,6 +4321,15 @@ "dev": true, "license": "MIT" }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 95423f1..eaf0cb1 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } diff --git a/src/components/VapiWidget.tsx b/src/components/VapiWidget.tsx index 06cc9e9..ae9d8ea 100644 --- a/src/components/VapiWidget.tsx +++ b/src/components/VapiWidget.tsx @@ -62,6 +62,7 @@ const VapiWidget: React.FC = ({ voiceShowTranscript, showTranscript = false, // deprecated voiceAutoReconnect = false, + voiceReconnectStorage = 'session', reconnectStorageKey = 'vapi_widget_web_call', // Consent configuration consentRequired, @@ -150,6 +151,7 @@ const VapiWidget: React.FC = ({ apiUrl, firstChatMessage: effectiveChatFirstMessage, voiceAutoReconnect, + voiceReconnectStorage, reconnectStorageKey, onCallStart: effectiveOnVoiceStart, onCallEnd: effectiveOnVoiceEnd, diff --git a/src/components/types.ts b/src/components/types.ts index 6931d8a..abf964e 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -49,6 +49,7 @@ export interface VapiWidgetProps { // Voice Configuration voiceShowTranscript?: boolean; voiceAutoReconnect?: boolean; + voiceReconnectStorage?: 'session' | 'cookies'; reconnectStorageKey?: string; // Consent Configuration diff --git a/src/hooks/useVapiCall.ts b/src/hooks/useVapiCall.ts index 90f676f..73573eb 100644 --- a/src/hooks/useVapiCall.ts +++ b/src/hooks/useVapiCall.ts @@ -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; @@ -25,6 +26,7 @@ export interface UseVapiCallOptions { apiUrl?: string; enabled?: boolean; voiceAutoReconnect?: boolean; + voiceReconnectStorage?: StorageType; reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; @@ -43,6 +45,7 @@ export const useVapiCall = ({ apiUrl, enabled = true, voiceAutoReconnect = false, + voiceReconnectStorage = 'session', reconnectStorageKey = 'vapi_widget_web_call', onCallStart, onCallEnd, @@ -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?.(); }; @@ -153,7 +159,7 @@ export const useVapiCall = ({ vapi.removeListener('message', handleMessage); vapi.removeListener('error', handleError); }; - }, [vapi, reconnectStorageKey]); + }, [vapi, reconnectStorageKey, voiceReconnectStorage]); useEffect(() => { return () => { @@ -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 } = {}) => { @@ -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'); @@ -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; } @@ -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) { diff --git a/src/hooks/useVapiWidget.ts b/src/hooks/useVapiWidget.ts index 46bb4f2..bee5a4c 100644 --- a/src/hooks/useVapiWidget.ts +++ b/src/hooks/useVapiWidget.ts @@ -16,6 +16,7 @@ export interface UseVapiWidgetOptions { apiUrl?: string; firstChatMessage?: string; voiceAutoReconnect?: boolean; + voiceReconnectStorage?: 'session' | 'cookies'; reconnectStorageKey?: string; onCallStart?: () => void; onCallEnd?: () => void; @@ -32,6 +33,7 @@ export const useVapiWidget = ({ apiUrl, firstChatMessage, voiceAutoReconnect = false, + voiceReconnectStorage = 'session', reconnectStorageKey, onCallStart, onCallEnd, @@ -68,6 +70,7 @@ export const useVapiWidget = ({ apiUrl, enabled: voiceEnabled, voiceAutoReconnect, + voiceReconnectStorage, reconnectStorageKey, onCallStart: () => { // In hybrid mode, clear all conversations when starting voice diff --git a/src/utils/vapiCallStorage.ts b/src/utils/vapiCallStorage.ts index aff90e4..36dc3b1 100644 --- a/src/utils/vapiCallStorage.ts +++ b/src/utils/vapiCallStorage.ts @@ -1,3 +1,5 @@ +import Cookies from 'js-cookie'; + export interface StoredCallData { webCallUrl: string; id?: string; @@ -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 => { diff --git a/src/widget/index.ts b/src/widget/index.ts index f6a6a26..a165dbf 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -169,6 +169,7 @@ function initializeWidgets() { // Voice Configuration 'voice-show-transcript': 'voiceShowTranscript', 'voice-auto-reconnect': 'voiceAutoReconnect', + 'voice-reconnect-storage': 'voiceReconnectStorage', 'reconnect-storage-key': 'reconnectStorageKey', // Consent Configuration From 374881c522af40ccb5b70497d921341e77c7d165 Mon Sep 17 00:00:00 2001 From: Steven Diaz Date: Sat, 1 Nov 2025 23:54:46 -0700 Subject: [PATCH 2/2] fix: format --- README.md | 80 +++++++++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 0d60bd8..87ce5be 100644 --- a/README.md +++ b/README.md @@ -71,43 +71,43 @@ The simplest way to add the widget to your website: ### Optional Props -| Prop | Type | Default | Description | -| ------------------------- | --------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------- | -| `mode` | `'voice' \| 'chat' \| 'hybrid'` | `'chat'` | Widget interaction mode | -| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | -| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' \| 'bottom-center'` | `'bottom-right'` | Screen position | -| `size` | `'tiny' \| 'compact' \| 'full'` | `'full'` | Widget size | -| `borderRadius` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Corner radius style | -| `apiUrl` | `string` | - | Custom API endpoint for chat mode | -| **Colors** | | | | -| `baseBgColor` | `string` | - | Main background color | -| `accentColor` | `string` | `'#14B8A6'` | Primary accent color | -| `ctaButtonColor` | `string` | `'#000000'` | CTA button background color | -| `ctaButtonTextColor` | `string` | `'#FFFFFF'` | CTA button text/icon color | -| **Text Labels** | | | | -| `title` | `string` | `'Talk with AI'` | Main widget title | -| `startButtonText` | `string` | `'Start'` | Voice call start button text | -| `endButtonText` | `string` | `'End Call'` | Voice call end button text | -| `ctaTitle` | `string` | _(uses title)_ | Floating button main text | -| `ctaSubtitle` | `string` | - | Floating button subtitle text | -| **Empty States** | | | | -| `voiceEmptyMessage` | `string` | - | Message when voice mode is empty | -| `voiceActiveEmptyMessage` | `string` | - | Message during active voice call | -| `chatEmptyMessage` | `string` | - | Message when chat is empty | -| `hybridEmptyMessage` | `string` | - | Message for hybrid mode | -| **Chat Configuration** | | | | -| `chatFirstMessage` | `string` | - | Initial assistant message in chat | -| `chatPlaceholder` | `string` | `'Type your message...'` | Chat input placeholder text | -| **Voice Configuration** | | | | -| `voiceShowTranscript` | `boolean` | `false` | Show/hide voice transcript | -| `voiceAutoReconnect` | `boolean` | `false` | Auto-reconnect to an active web call (see `voiceReconnectStorage` for scope) | -| `voiceReconnectStorage` | `'session' \| 'cookies'` | `'session'` | Storage type: 'session' (same tab only) or 'cookies' (same tab across subdomains) | -| `reconnectStorageKey` | `string` | `'vapi_widget_web_call'` | Key for storing reconnection data | -| **Consent Configuration** | | | | -| `consentRequired` | `boolean` | `false` | Show consent form before first use | -| `consentTitle` | `string` | `"Terms and conditions"` | Consent form title | -| `consentContent` | `string` | _(default message)_ | Terms & conditions content | -| `consentStorageKey` | `string` | `"vapi_widget_consent"` | Key for storing consent | +| Prop | Type | Default | Description | +| ------------------------- | --------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- | +| `mode` | `'voice' \| 'chat' \| 'hybrid'` | `'chat'` | Widget interaction mode | +| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | +| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' \| 'bottom-center'` | `'bottom-right'` | Screen position | +| `size` | `'tiny' \| 'compact' \| 'full'` | `'full'` | Widget size | +| `borderRadius` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Corner radius style | +| `apiUrl` | `string` | - | Custom API endpoint for chat mode | +| **Colors** | | | | +| `baseBgColor` | `string` | - | Main background color | +| `accentColor` | `string` | `'#14B8A6'` | Primary accent color | +| `ctaButtonColor` | `string` | `'#000000'` | CTA button background color | +| `ctaButtonTextColor` | `string` | `'#FFFFFF'` | CTA button text/icon color | +| **Text Labels** | | | | +| `title` | `string` | `'Talk with AI'` | Main widget title | +| `startButtonText` | `string` | `'Start'` | Voice call start button text | +| `endButtonText` | `string` | `'End Call'` | Voice call end button text | +| `ctaTitle` | `string` | _(uses title)_ | Floating button main text | +| `ctaSubtitle` | `string` | - | Floating button subtitle text | +| **Empty States** | | | | +| `voiceEmptyMessage` | `string` | - | Message when voice mode is empty | +| `voiceActiveEmptyMessage` | `string` | - | Message during active voice call | +| `chatEmptyMessage` | `string` | - | Message when chat is empty | +| `hybridEmptyMessage` | `string` | - | Message for hybrid mode | +| **Chat Configuration** | | | | +| `chatFirstMessage` | `string` | - | Initial assistant message in chat | +| `chatPlaceholder` | `string` | `'Type your message...'` | Chat input placeholder text | +| **Voice Configuration** | | | | +| `voiceShowTranscript` | `boolean` | `false` | Show/hide voice transcript | +| `voiceAutoReconnect` | `boolean` | `false` | Auto-reconnect to an active web call (see `voiceReconnectStorage` for scope) | +| `voiceReconnectStorage` | `'session' \| 'cookies'` | `'session'` | Storage type: 'session' (same tab only) or 'cookies' (same tab across subdomains) | +| `reconnectStorageKey` | `string` | `'vapi_widget_web_call'` | Key for storing reconnection data | +| **Consent Configuration** | | | | +| `consentRequired` | `boolean` | `false` | Show consent form before first use | +| `consentTitle` | `string` | `"Terms and conditions"` | Consent form title | +| `consentContent` | `string` | _(default message)_ | Terms & conditions content | +| `consentStorageKey` | `string` | `"vapi_widget_consent"` | Key for storing consent | ### Event Callbacks @@ -280,14 +280,14 @@ Use this approach if your environment doesn't support custom elements or for bet ### Voice-Only with Auto-Reconnect -````tsx +```tsx -```` +``` ### Voice with Cross-Subdomain Reconnection @@ -315,7 +315,7 @@ npm install # Build everything npm run build:all -```` +``` ### Development Commands