@vanira/sdk-react-native 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
package/src/cdn.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { VaniraWidget } from './ui/VaniraWidget';
2
+ import { VaniraAI } from './core/VaniraAI';
3
+ import { WebRTCClient } from './core/WebRTCClient';
4
+
5
+ // ─── Register Custom Element ─────────────────────────────────────────────────
6
+ if (!customElements.get('vanira-convai')) {
7
+ customElements.define('vanira-convai', VaniraWidget);
8
+ }
9
+
10
+ // ─── Global window exports ───────────────────────────────────────────────────
11
+ if (typeof window !== 'undefined') {
12
+ // Legacy widget registration
13
+ (window as any).VaniraConvAI = VaniraWidget;
14
+ // New SDK class — integrators can do: const client = new window.VaniraAI({ agentId: '...' })
15
+ (window as any).VaniraAI = VaniraAI;
16
+ // Low-level WebRTC client — for direct call management + uploadMedia()
17
+ (window as any).WebRTCClient = WebRTCClient;
18
+ }
19
+
20
+ // ─── Auto-inject Widget via script attributes ────────────────────────────────
21
+ const currentScript = document.currentScript;
22
+ if (currentScript) {
23
+ const widgetId = currentScript.getAttribute('data-widget-id') || currentScript.getAttribute('widget-id');
24
+ const agentId = currentScript.getAttribute('data-agent-id') || currentScript.getAttribute('agent-id');
25
+
26
+ if (widgetId || agentId) {
27
+ const position = currentScript.getAttribute('data-position') || 'bottom-right';
28
+ const primaryColor = currentScript.getAttribute('data-primary-color') || currentScript.getAttribute('primary-color') || '#000000';
29
+ const secondaryColor = currentScript.getAttribute('data-secondary-color') || currentScript.getAttribute('secondary-color');
30
+ const gradient = currentScript.getAttribute('data-gradient') || currentScript.getAttribute('gradient');
31
+ const serverUrl = currentScript.getAttribute('data-server-url') || currentScript.getAttribute('server-url');
32
+ const pkKey = currentScript.getAttribute('data-pk-key') || currentScript.getAttribute('pk-key');
33
+
34
+ const widget = document.createElement('vanira-convai');
35
+ if (widgetId) widget.setAttribute('widget-id', widgetId);
36
+ if (agentId) widget.setAttribute('agent-id', agentId);
37
+ if (pkKey) widget.setAttribute('pk-key', pkKey);
38
+ widget.setAttribute('position', position);
39
+ widget.setAttribute('primary-color', primaryColor);
40
+ if (secondaryColor) widget.setAttribute('secondary-color', secondaryColor);
41
+ if (gradient) widget.setAttribute('gradient', gradient);
42
+ if (serverUrl) widget.setAttribute('server-url', serverUrl);
43
+
44
+ if (document.readyState === 'loading') {
45
+ document.addEventListener('DOMContentLoaded', () => document.body.appendChild(widget));
46
+ } else {
47
+ document.body.appendChild(widget);
48
+ }
49
+
50
+ // ─── Listen for vaniraai:tool_call events from the widget ──────────────
51
+ // Integrators can attach their own handlers:
52
+ // document.querySelector('vanira-convai').addEventListener('vaniraai:tool_call', (e) => { ... })
53
+ // But also expose a default global handler slot for convenience.
54
+ widget.addEventListener('vaniraai:tool_call', (e: Event) => {
55
+ const toolCall = (e as CustomEvent).detail;
56
+ console.log('[VaniraAI Widget] Tool call received by host page:', toolCall.name, toolCall.arguments);
57
+ // If integrator registered a global handler, call it
58
+ if (typeof (window as any).__vaniraai_tool_handler === 'function') {
59
+ (window as any).__vaniraai_tool_handler(toolCall, {
60
+ sendResult: (result: any) => {
61
+ // Future: wire back to widget vaniraClient
62
+ console.log('[VaniraAI] sendResult called:', result);
63
+ }
64
+ });
65
+ }
66
+ });
67
+ }
68
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Persists the latest call session per agent so "Continue" resumes context.
3
+ * Uses the runtime StorageAdapter (MMKV / AsyncStorage / in-memory).
4
+ */
5
+
6
+ import type { StorageAdapter } from '../adapters/storage/StorageAdapter';
7
+
8
+ export interface StoredCallSession {
9
+ callId: string;
10
+ prospectId?: string;
11
+ updatedAt: number;
12
+ }
13
+
14
+ const LEGACY_CALL_KEY = 'vanira_latest_call_id';
15
+ const LEGACY_PROSPECT_KEY = 'vanira_prospect_id';
16
+
17
+ function agentCallKey(agentId: string): string {
18
+ return `vanira_latest_call_id_${agentId}`;
19
+ }
20
+
21
+ function agentProspectKey(agentId: string): string {
22
+ return `vanira_prospect_id_${agentId}`;
23
+ }
24
+
25
+ function agentMetaKey(agentId: string): string {
26
+ return `vanira_call_meta_${agentId}`;
27
+ }
28
+
29
+ function safeGet(storage: StorageAdapter, key: string): string | null {
30
+ try {
31
+ return storage.getItem(key);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Load the newest stored call session for an agent (continue / resume). */
38
+ export function loadContinueSession(
39
+ storage: StorageAdapter,
40
+ agentId: string,
41
+ ): StoredCallSession | null {
42
+ if (!agentId) {
43
+ return null;
44
+ }
45
+
46
+ const candidates: StoredCallSession[] = [];
47
+
48
+ const metaRaw = safeGet(storage, agentMetaKey(agentId));
49
+ if (metaRaw) {
50
+ try {
51
+ const meta = JSON.parse(metaRaw) as StoredCallSession;
52
+ if (meta?.callId) {
53
+ candidates.push({
54
+ callId: meta.callId,
55
+ prospectId: meta.prospectId,
56
+ updatedAt: meta.updatedAt || 0,
57
+ });
58
+ }
59
+ } catch {
60
+ /* ignore corrupt meta */
61
+ }
62
+ }
63
+
64
+ const pushCandidate = (
65
+ callId: string | null,
66
+ prospectId: string | null | undefined,
67
+ updatedAt: number,
68
+ ) => {
69
+ if (!callId) {
70
+ return;
71
+ }
72
+ candidates.push({
73
+ callId,
74
+ prospectId: prospectId || undefined,
75
+ updatedAt,
76
+ });
77
+ };
78
+
79
+ const agentProspect = safeGet(storage, agentProspectKey(agentId));
80
+ const legacyProspect = safeGet(storage, LEGACY_PROSPECT_KEY);
81
+
82
+ pushCandidate(
83
+ safeGet(storage, agentCallKey(agentId)),
84
+ agentProspect || legacyProspect,
85
+ 2,
86
+ );
87
+ pushCandidate(
88
+ safeGet(storage, LEGACY_CALL_KEY),
89
+ legacyProspect,
90
+ 0,
91
+ );
92
+
93
+ if (!candidates.length) {
94
+ return null;
95
+ }
96
+
97
+ candidates.sort((a, b) => b.updatedAt - a.updatedAt);
98
+ return candidates[0];
99
+ }
100
+
101
+ /** Persist call + prospect IDs after POST /calls/create succeeds. */
102
+ export function saveCallSession(
103
+ storage: StorageAdapter,
104
+ agentId: string,
105
+ callId: string,
106
+ prospectId?: string,
107
+ ): void {
108
+ if (!agentId || !callId) {
109
+ return;
110
+ }
111
+
112
+ const meta: StoredCallSession = {
113
+ callId,
114
+ prospectId,
115
+ updatedAt: Date.now(),
116
+ };
117
+
118
+ try {
119
+ storage.setItem(agentMetaKey(agentId), JSON.stringify(meta));
120
+ storage.setItem(agentCallKey(agentId), callId);
121
+ storage.setItem(LEGACY_CALL_KEY, callId);
122
+
123
+ if (prospectId) {
124
+ storage.setItem(agentProspectKey(agentId), prospectId);
125
+ storage.setItem(LEGACY_PROSPECT_KEY, prospectId);
126
+ }
127
+ } catch (e) {
128
+ console.warn('[CallSessionStore] Failed to persist call session:', e);
129
+ }
130
+ }
131
+
132
+ export function hasContinueSession(
133
+ storage: StorageAdapter,
134
+ agentId: string,
135
+ ): boolean {
136
+ return !!loadContinueSession(storage, agentId)?.callId;
137
+ }
@@ -0,0 +1,83 @@
1
+
2
+ export class DraggableController {
3
+ private isDragging = false;
4
+ private startX = 0;
5
+ private startY = 0;
6
+ private initialX = 0;
7
+ private initialY = 0;
8
+ private dragThreshold = 5;
9
+
10
+ constructor(
11
+ private target: HTMLElement,
12
+ private onDrag: (x: number, y: number) => void,
13
+ private onDragEnd: () => void
14
+ ) {
15
+ this.init();
16
+ }
17
+
18
+ private init() {
19
+ this.target.addEventListener('mousedown', (e) => this.onMouseDown(e as MouseEvent));
20
+ this.target.addEventListener('touchstart', (e) => this.onTouchStart(e as TouchEvent), { passive: false });
21
+
22
+ window.addEventListener('mousemove', (e) => this.onMouseMove(e as MouseEvent));
23
+ window.addEventListener('touchmove', (e) => this.onTouchMove(e as TouchEvent), { passive: false });
24
+
25
+ window.addEventListener('mouseup', () => this.onMouseUp());
26
+ window.addEventListener('touchend', () => this.onMouseUp());
27
+ }
28
+
29
+ private onMouseDown(e: MouseEvent) {
30
+ this.startDrag(e.clientX, e.clientY);
31
+ }
32
+
33
+ private onTouchStart(e: TouchEvent) {
34
+ const touch = e.touches[0];
35
+ if (touch) this.startDrag(touch.clientX, touch.clientY);
36
+ }
37
+
38
+ private startDrag(x: number, y: number) {
39
+ this.startX = x;
40
+ this.startY = y;
41
+ this.isDragging = false;
42
+
43
+ const rect = this.target.getBoundingClientRect();
44
+ this.initialX = rect.left;
45
+ this.initialY = rect.top;
46
+ }
47
+
48
+ private onMouseMove(e: MouseEvent) {
49
+ this.moveDrag(e.clientX, e.clientY);
50
+ }
51
+
52
+ private onTouchMove(e: TouchEvent) {
53
+ const touch = e.touches[0];
54
+ if (touch) {
55
+ if (e.cancelable) e.preventDefault();
56
+ this.moveDrag(touch.clientX, touch.clientY);
57
+ }
58
+ }
59
+
60
+ private moveDrag(x: number, y: number) {
61
+ if (this.startX === 0) return;
62
+
63
+ const dx = x - this.startX;
64
+ const dy = y - this.startY;
65
+
66
+ if (!this.isDragging && (Math.abs(dx) > this.dragThreshold || Math.abs(dy) > this.dragThreshold)) {
67
+ this.isDragging = true;
68
+ }
69
+
70
+ if (this.isDragging) {
71
+ this.onDrag(this.initialX + dx, this.initialY + dy);
72
+ }
73
+ }
74
+
75
+ private onMouseUp() {
76
+ if (this.isDragging) {
77
+ this.onDragEnd();
78
+ }
79
+ this.isDragging = false;
80
+ this.startX = 0;
81
+ this.startY = 0;
82
+ }
83
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * SessionManager
3
+ * ==============
4
+ * Manages per-tab widget sessions using:
5
+ * - sessionStorage → tab_id (unique per browser tab, survives refresh, gone on tab close)
6
+ * - localStorage → session data (prospect_id, chat_id, messages — persists across refreshes)
7
+ * - BroadcastChannel → real-time cross-tab communication
8
+ * - Heartbeat → detect dead tabs (no heartbeat for 15s → tab considered gone)
9
+ *
10
+ * Key behaviours:
11
+ * 1. Each tab gets a unique tab_id.
12
+ * 2. Only one tab is "active" at a time (owns the session).
13
+ * 3. When a second tab loads the widget, it detects the conflict and fires onTabConflict().
14
+ * 4. Chat messages, prospect_id and chat_id are persisted so a page refresh restores the session.
15
+ * 5. Tab closure is detected via heartbeat expiry — the next tab to init auto-claims.
16
+ */
17
+
18
+ export interface StoredMessage {
19
+ role: 'user' | 'assistant';
20
+ content: string;
21
+ timestamp: number;
22
+ }
23
+
24
+ export interface SessionData {
25
+ tabId: string;
26
+ prospectId: string;
27
+ chatId: string | null;
28
+ conversationId: string | null;
29
+ messages: StoredMessage[];
30
+ lastActive: number; // unix ms — heartbeat timestamp
31
+ }
32
+
33
+ export type SessionEvent =
34
+ | 'tab_conflict' // another tab already owns the session
35
+ | 'session_claimed' // this tab successfully claimed the session
36
+ | 'session_restored' // existing session loaded (prospect/chatId/messages recovered)
37
+ | 'tab_took_over' // a different tab stole the session from this one
38
+ | 'session_cleared'; // session was explicitly cleared
39
+
40
+ export class SessionManager {
41
+ private tabId: string;
42
+ private sessionKey: string;
43
+ private channelName: string;
44
+
45
+ private channel: BroadcastChannel | null = null;
46
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
47
+ private listeners: Map<SessionEvent, Set<Function>> = new Map();
48
+
49
+ /** How often (ms) the active tab broadcasts its heartbeat */
50
+ private static readonly HEARTBEAT_INTERVAL_MS = 5_000;
51
+ /** How old (ms) a heartbeat must be before we consider the tab dead */
52
+ private static readonly HEARTBEAT_TIMEOUT_MS = 15_000;
53
+
54
+ constructor(widgetId: string) {
55
+ const safeId = widgetId.replace(/[^a-z0-9_-]/gi, '_');
56
+ this.sessionKey = `vaniraai_session_${safeId}`;
57
+ this.channelName = `vaniraai_channel_${safeId}`;
58
+ this.tabId = this._getOrCreateTabId();
59
+
60
+ this._setupChannel();
61
+ }
62
+
63
+ // ─── Public API ──────────────────────────────────────────────────────────
64
+
65
+ /** Returns this tab's unique ID (stable across same-tab page refreshes). */
66
+ getTabId(): string {
67
+ return this.tabId;
68
+ }
69
+
70
+ /**
71
+ * Try to claim the session for this tab.
72
+ * Returns true → session claimed (or restored after stale prior tab).
73
+ * Returns false → another live tab already owns the session.
74
+ */
75
+ claimSession(): boolean {
76
+ const existing = this._loadSession();
77
+
78
+ if (existing) {
79
+ const age = Date.now() - existing.lastActive;
80
+ const isOwnTab = existing.tabId === this.tabId;
81
+ const isStaletab = age > SessionManager.HEARTBEAT_TIMEOUT_MS;
82
+
83
+ if (isOwnTab) {
84
+ // This tab already owned the session (e.g. page refresh)
85
+ this._startHeartbeat();
86
+ this._emit('session_restored');
87
+ return true;
88
+ }
89
+
90
+ if (!isStaletab) {
91
+ // A live different tab owns the session → conflict
92
+ this._emit('tab_conflict');
93
+ return false;
94
+ }
95
+ // Stale tab — safe to take over
96
+ }
97
+
98
+ // No session or stale session → claim it
99
+ const draft: SessionData = existing
100
+ ? { ...existing, tabId: this.tabId, lastActive: Date.now() }
101
+ : { tabId: this.tabId, prospectId: '', chatId: null, conversationId: null, messages: [], lastActive: Date.now() };
102
+
103
+ this._saveSession(draft);
104
+ this._startHeartbeat();
105
+
106
+ // Notify other tabs (if any) that this tab took over
107
+ this._broadcast({ type: 'took_over', tabId: this.tabId });
108
+
109
+ const hadPrior = !!(existing?.prospectId);
110
+ this._emit(hadPrior ? 'session_restored' : 'session_claimed');
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Force-claim the session even if another tab owns it.
116
+ * Call this when the user explicitly clicks "Use here" in the conflict UI.
117
+ */
118
+ forceClaimSession(): void {
119
+ const existing = this._loadSession();
120
+ const draft: SessionData = existing
121
+ ? { ...existing, tabId: this.tabId, lastActive: Date.now() }
122
+ : { tabId: this.tabId, prospectId: '', chatId: null, conversationId: null, messages: [], lastActive: Date.now() };
123
+
124
+ this._saveSession(draft);
125
+ this._startHeartbeat();
126
+ this._broadcast({ type: 'took_over', tabId: this.tabId });
127
+ this._emit('session_claimed');
128
+ }
129
+
130
+ /** Save / update the prospect and chat IDs for this session. */
131
+ saveIds(prospectId: string, chatId: string | null, conversationId?: string | null): void {
132
+ const session = this._loadSession() ?? this._blankSession();
133
+ this._saveSession({ ...session, prospectId, chatId, conversationId: conversationId ?? session.conversationId ?? null, tabId: this.tabId, lastActive: Date.now() });
134
+ }
135
+
136
+ /** Append a message to the persisted history. */
137
+ pushMessage(role: 'user' | 'assistant', content: string): void {
138
+ const session = this._loadSession() ?? this._blankSession();
139
+ session.messages.push({ role, content, timestamp: Date.now() });
140
+
141
+ // Keep last 100 messages to avoid bloating localStorage
142
+ if (session.messages.length > 100) {
143
+ session.messages = session.messages.slice(-100);
144
+ }
145
+
146
+ this._saveSession({ ...session, tabId: this.tabId, lastActive: Date.now() });
147
+ }
148
+
149
+ /** Update the content of the last assistant message (streaming). */
150
+ updateLastAssistantMessage(content: string): void {
151
+ const session = this._loadSession() ?? this._blankSession();
152
+ const msgs = session.messages;
153
+
154
+ // Find the last assistant message that is a placeholder (empty) or the last one
155
+ let found = false;
156
+ for (let i = msgs.length - 1; i >= 0; i--) {
157
+ if (msgs[i].role === 'assistant' && msgs[i].content === '') {
158
+ // Update the placeholder we pushed before streaming started
159
+ msgs[i].content = content;
160
+ found = true;
161
+ break;
162
+ }
163
+ }
164
+
165
+ if (!found) {
166
+ // Fallback: update the very last assistant message (older approach)
167
+ for (let i = msgs.length - 1; i >= 0; i--) {
168
+ if (msgs[i].role === 'assistant') {
169
+ msgs[i].content = content;
170
+ found = true;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+
176
+ if (!found) {
177
+ // No assistant message at all — just append
178
+ msgs.push({ role: 'assistant', content, timestamp: Date.now() });
179
+ }
180
+
181
+ this._saveSession({ ...session, tabId: this.tabId, lastActive: Date.now() });
182
+ }
183
+
184
+ /** Returns the currently persisted session data (null if none). */
185
+ getSession(): SessionData | null {
186
+ return this._loadSession();
187
+ }
188
+
189
+ /** Clears ALL session data (prospect_id, chat_id, messages). */
190
+ clearSession(): void {
191
+ localStorage.removeItem(this.sessionKey);
192
+ this._broadcast({ type: 'session_cleared', tabId: this.tabId });
193
+ this._emit('session_cleared');
194
+ }
195
+
196
+ /** Clears chat-specific session data (chatId, conversationId, messages) but keeps the prospectId. */
197
+ clearChatKeepProspect(): void {
198
+ const session = this._loadSession();
199
+ if (session) {
200
+ this._saveSession({
201
+ ...session,
202
+ chatId: null,
203
+ conversationId: null,
204
+ messages: [],
205
+ lastActive: Date.now()
206
+ });
207
+ }
208
+ }
209
+
210
+ /** Stop the heartbeat and release resources. Call on widget destroy. */
211
+ destroy(): void {
212
+ this._stopHeartbeat();
213
+ if (this.channel) {
214
+ this.channel.close();
215
+ this.channel = null;
216
+ }
217
+ }
218
+
219
+ // ─── Event Emitter ───────────────────────────────────────────────────────
220
+
221
+ on(event: SessionEvent, cb: Function): this {
222
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
223
+ this.listeners.get(event)!.add(cb);
224
+ return this;
225
+ }
226
+
227
+ off(event: SessionEvent, cb: Function): this {
228
+ this.listeners.get(event)?.delete(cb);
229
+ return this;
230
+ }
231
+
232
+ // ─── Private ─────────────────────────────────────────────────────────────
233
+
234
+ private _getOrCreateTabId(): string {
235
+ let id = sessionStorage.getItem('vaniraai_tab_id');
236
+ if (!id) {
237
+ id = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
238
+ sessionStorage.setItem('vaniraai_tab_id', id);
239
+ }
240
+ return id;
241
+ }
242
+
243
+ private _setupChannel(): void {
244
+ if (typeof BroadcastChannel === 'undefined') return; // SSR / old browsers
245
+
246
+ this.channel = new BroadcastChannel(this.channelName);
247
+ this.channel.onmessage = (ev) => {
248
+ const msg = ev.data as { type: string; tabId: string };
249
+
250
+ if (msg.tabId === this.tabId) return; // Ignore own messages
251
+
252
+ if (msg.type === 'took_over') {
253
+ // Another tab stole the session from us
254
+ this._stopHeartbeat();
255
+ this._emit('tab_took_over');
256
+ }
257
+
258
+ if (msg.type === 'heartbeat') {
259
+ // Another tab is alive — if that tab isn't us, we're in conflict
260
+ // (handled at claimSession time, not runtime)
261
+ }
262
+
263
+ if (msg.type === 'session_cleared') {
264
+ this._emit('session_cleared');
265
+ }
266
+ };
267
+ }
268
+
269
+ private _broadcast(data: object): void {
270
+ this.channel?.postMessage(data);
271
+ }
272
+
273
+ private _startHeartbeat(): void {
274
+ this._stopHeartbeat();
275
+ this.heartbeatTimer = setInterval(() => {
276
+ const session = this._loadSession();
277
+ if (session && session.tabId === this.tabId) {
278
+ this._saveSession({ ...session, lastActive: Date.now() });
279
+ this._broadcast({ type: 'heartbeat', tabId: this.tabId });
280
+ }
281
+ }, SessionManager.HEARTBEAT_INTERVAL_MS);
282
+ }
283
+
284
+ private _stopHeartbeat(): void {
285
+ if (this.heartbeatTimer !== null) {
286
+ clearInterval(this.heartbeatTimer);
287
+ this.heartbeatTimer = null;
288
+ }
289
+ }
290
+
291
+ private _loadSession(): SessionData | null {
292
+ try {
293
+ const raw = localStorage.getItem(this.sessionKey);
294
+ return raw ? (JSON.parse(raw) as SessionData) : null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ private _saveSession(data: SessionData): void {
301
+ try {
302
+ localStorage.setItem(this.sessionKey, JSON.stringify(data));
303
+ } catch (e) {
304
+ console.warn('[VaniraAI] SessionManager: failed to save session', e);
305
+ }
306
+ }
307
+
308
+ private _blankSession(): SessionData {
309
+ return {
310
+ tabId: this.tabId,
311
+ prospectId: '',
312
+ chatId: null,
313
+ conversationId: null,
314
+ messages: [],
315
+ lastActive: Date.now(),
316
+ };
317
+ }
318
+
319
+ private _emit(event: SessionEvent): void {
320
+ this.listeners.get(event)?.forEach(cb => cb());
321
+ }
322
+ }