@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
@@ -0,0 +1,1066 @@
1
+ import { ChatService } from '../../api/services/ChatService';
2
+ import { AbstractWidgetProvider } from '../abstraction/AbstractWidgetProvider';
3
+ import { VaniraAI } from '../../core/VaniraAI';
4
+ import { SessionManager } from '../../core/SessionManager';
5
+ import { WidgetMode } from '../../types';
6
+ import { generateThemeVars, widgetIcons, icons } from '../styles';
7
+ import { widgetStyles as baseCss } from '../styles/widget.css';
8
+ import { FloatingButton, Panel, FloatingWelcomeChips, WelcomeChip } from '../components';
9
+ import { VoiceOnlyView, ChatOnlyView, ChatVoiceView, AvatarOnlyView, ChatAvatarView } from '../views';
10
+ import { VaniraChatAdapter } from '../adapters/VaniraChatAdapter';
11
+ import { WidgetPresetRenderer } from '../presets/WidgetPresetRenderer';
12
+
13
+ export class VaniraInternalProvider extends AbstractWidgetProvider {
14
+ private vaniraClient: VaniraAI | null = null;
15
+ private currentView: any = null;
16
+ private presetRenderer: WidgetPresetRenderer | null = null;
17
+ private isPanelOpen: boolean = false;
18
+ private callActive: boolean = false;
19
+ private eventSource: { close: () => void } | null = null;
20
+
21
+ // Components
22
+ private floatingButton: FloatingButton | null = null;
23
+ private floatingWelcomeChips: FloatingWelcomeChips | null = null;
24
+ private panel: Panel | null = null;
25
+ private welcomeChipsData: WelcomeChip[] = [];
26
+
27
+ // Chat Adapter
28
+ private chatAdapter: VaniraChatAdapter;
29
+
30
+ // ─── Session Manager ─────────────────────────────────────────────────────
31
+ private sessionManager: SessionManager | null = null;
32
+ /** Whether this tab currently owns the widget session. */
33
+ private sessionActive: boolean = false;
34
+
35
+ // Config derived state
36
+ private widgetMode: WidgetMode = 'voice_only';
37
+ private agentId: string = '';
38
+ private prospectGroupId: string = '';
39
+ private chatServerUrl: string = '';
40
+
41
+ private prospectId: string = '';
42
+ private chatId: string | null = null;
43
+ private conversationId: string | null = null;
44
+ private widgetId: string = '';
45
+ private pkKey: string = '';
46
+ private chatInitialized: boolean = false;
47
+
48
+ // Appearance (Defaults)
49
+ private primaryColor: string = '#000000';
50
+ private secondaryColor: string = '#111111';
51
+ private gradient: string | null = null;
52
+ private positionType: 'fixed' | 'absolute' = 'fixed';
53
+ private position: string = 'bottom-right';
54
+ private widgetIcon: string | null = null;
55
+ private iconConfig: { logo_text?: string; title?: string; button_text?: string } | null = null;
56
+ private chatWelcomeMessage: any = null;
57
+
58
+ constructor(config: any) {
59
+ super(config);
60
+ this.chatAdapter = new VaniraChatAdapter();
61
+ this.processConfig(config);
62
+ }
63
+
64
+ // ─── Session Initialisation ───────────────────────────────────────────────
65
+
66
+ /**
67
+ * Boot the SessionManager.
68
+ * Called once we have a stable widgetId to key the session on.
69
+ * Returns true if this tab owns the session, false if another tab owns it.
70
+ */
71
+ private initSessionManager(): boolean {
72
+ const key = this.widgetId || this.agentId || 'default';
73
+ this.sessionManager = new SessionManager(key);
74
+
75
+ // Wire cross-tab events
76
+ this.sessionManager.on('tab_took_over', () => {
77
+ // Another tab stole the session → show "taken over" banner in this tab
78
+ console.warn('[VaniraAI] Session taken over by another tab.');
79
+ this.sessionActive = false;
80
+ this.showTabConflictBanner('taken_over');
81
+ });
82
+
83
+ this.sessionManager.on('session_cleared', () => {
84
+ console.log('[VaniraAI] Session cleared externally.');
85
+ });
86
+
87
+ const claimed = this.sessionManager.claimSession();
88
+ this.sessionActive = claimed;
89
+ return claimed;
90
+ }
91
+
92
+ /**
93
+ * Restore persisted chat messages into the chat window.
94
+ * Called after the view is rendered.
95
+ */
96
+ private restoreSessionMessages(): void {
97
+ const session = this.sessionManager?.getSession();
98
+ if (!session) return;
99
+
100
+ const cw = this.currentView?.getChatWindow?.();
101
+ if (!cw) return;
102
+
103
+ // Filter out empty placeholder messages
104
+ const visibleMessages = session.messages.filter(msg => msg.content.trim() !== '');
105
+
106
+ // Load persisted call records
107
+ const callKey = `vanira_calls_${this.widgetId || this.agentId}`;
108
+ const callRecords: Array<{ durationMs: number; startedAt: number }> =
109
+ JSON.parse(localStorage.getItem(callKey) || '[]');
110
+
111
+ if (visibleMessages.length === 0 && callRecords.length === 0) return;
112
+
113
+ cw.clearMessages();
114
+
115
+ // Merge messages and call records by timestamp, then render in order
116
+ type Entry =
117
+ | { type: 'msg'; role: 'user' | 'assistant'; content: string; ts: number }
118
+ | { type: 'call'; durationMs: number; startedAt: number; ts: number };
119
+
120
+ const entries: Entry[] = [
121
+ ...visibleMessages.map((m, i) => ({
122
+ type: 'msg' as const,
123
+ role: m.role,
124
+ content: m.content,
125
+ // If session messages have no timestamp, use index as tiebreaker
126
+ ts: (m as any).timestamp ?? i,
127
+ })),
128
+ ...callRecords.map(r => ({
129
+ type: 'call' as const,
130
+ durationMs: r.durationMs,
131
+ startedAt: r.startedAt,
132
+ ts: r.startedAt,
133
+ })),
134
+ ];
135
+
136
+ entries.sort((a, b) => a.ts - b.ts);
137
+
138
+ entries.forEach((entry) => {
139
+ if (entry.type === 'msg') {
140
+ cw.addMessage(entry.role, entry.content, entry.ts);
141
+ } else {
142
+ cw.addCallRecord(entry.durationMs, entry.startedAt);
143
+ }
144
+ });
145
+
146
+ console.log(`[VaniraAI] Restored ${visibleMessages.length} messages + ${callRecords.length} call records from session.`);
147
+ }
148
+
149
+ // ─── Tab Conflict UI ─────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Renders a friendly overlay when:
153
+ * - 'conflict' → this tab tried to open but another tab owns the session
154
+ * - 'taken_over' → another tab stole the session while this tab was active
155
+ */
156
+ private showTabConflictBanner(reason: 'conflict' | 'taken_over'): void {
157
+ if (!this.root) return;
158
+
159
+ // Remove any existing banner
160
+ this.root.querySelector('.vanira-tab-conflict-banner')?.remove();
161
+
162
+ if (reason === 'conflict') {
163
+ this.sessionManager?.forceClaimSession();
164
+ this.sessionActive = true;
165
+ if (this.widgetMode.includes('chat')) {
166
+ this.initializeChatSession(false);
167
+ }
168
+ }
169
+ // If 'taken_over', do nothing. The tab silently becomes dormant.
170
+ }
171
+
172
+ private static audioContext: AudioContext | null = null;
173
+ private playMessageSound(type: 'send' | 'receive') {
174
+ try {
175
+ if (!VaniraInternalProvider.audioContext) {
176
+ const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
177
+ if (!AudioContextClass) return;
178
+ VaniraInternalProvider.audioContext = new AudioContextClass();
179
+ }
180
+ const ctx = VaniraInternalProvider.audioContext;
181
+
182
+ // Required by modern browsers to play sound after interaction
183
+ if (ctx.state === 'suspended') {
184
+ ctx.resume().catch(() => { });
185
+ }
186
+
187
+ const osc = ctx.createOscillator();
188
+ const gain = ctx.createGain();
189
+ osc.connect(gain);
190
+ gain.connect(ctx.destination);
191
+
192
+ const now = ctx.currentTime;
193
+ if (type === 'send') {
194
+ // A subtle rising pop for sending
195
+ osc.type = 'sine';
196
+ osc.frequency.setValueAtTime(300, now);
197
+ osc.frequency.exponentialRampToValueAtTime(500, now + 0.05);
198
+ gain.gain.setValueAtTime(0, now);
199
+ gain.gain.linearRampToValueAtTime(0.1, now + 0.01);
200
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
201
+ osc.start(now);
202
+ osc.stop(now + 0.1);
203
+ } else {
204
+ // A friendly bright pop for receiving
205
+ osc.type = 'sine';
206
+ osc.frequency.setValueAtTime(500, now);
207
+ osc.frequency.exponentialRampToValueAtTime(800, now + 0.1);
208
+ gain.gain.setValueAtTime(0, now);
209
+ gain.gain.linearRampToValueAtTime(0.15, now + 0.02);
210
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
211
+ osc.start(now);
212
+ osc.stop(now + 0.2);
213
+ }
214
+ } catch (e) {
215
+ console.warn('[VaniraAI] Audio feedback failed', e);
216
+ }
217
+ }
218
+
219
+ // ─── Chat Logic ───────────────────────────────────────────────────────────
220
+
221
+ private handleWelcomePayload(payload: any, role: 'user' | 'assistant' = 'assistant') {
222
+ if (!payload || this.widgetMode === 'voice_only') return;
223
+
224
+ // 1. Update Floating Chips (Stateless/Floating UI - always do this)
225
+ if (payload.buttons && Array.isArray(payload.buttons)) {
226
+ this.welcomeChipsData = payload.buttons;
227
+ } else if (payload.widget?.type === 'button_list' && payload.widget.data?.buttons) {
228
+ this.welcomeChipsData = payload.widget.data.buttons;
229
+ }
230
+ this.updateWelcomeChipsVisibility();
231
+
232
+ // 2. Update Chat Window (Stateful UI - only if window is ready)
233
+ const cw = this.currentView?.getChatWindow?.();
234
+
235
+ // Robust Extraction of text from JSON or object
236
+ let welcomeText = payload.text || payload.response || payload.content || payload.message || '';
237
+
238
+ // If it's still empty, use the user's preferred fallback
239
+ if (!welcomeText && !this.welcomeChipsData.length) {
240
+ welcomeText = 'Hey! how can I help you ?';
241
+ }
242
+
243
+ // If the payload itself is a string that might be JSON (Double safety)
244
+ if (!welcomeText && typeof payload === 'string' && payload.trim().startsWith('{')) {
245
+ try {
246
+ const parsed = JSON.parse(payload);
247
+ welcomeText = parsed.text || parsed.response || parsed.content || parsed.message || '';
248
+ // If it has buttons inside nested widget
249
+ if (parsed.widget?.type === 'button_list' && parsed.widget.data?.buttons) {
250
+ this.welcomeChipsData = parsed.widget.data.buttons;
251
+ }
252
+ } catch (e) { }
253
+ }
254
+
255
+ console.log('[VaniraAI] Processing Welcome Payload:', {
256
+ hasText: !!welcomeText,
257
+ chipsCount: this.welcomeChipsData.length,
258
+ hasChatWindow: !!cw,
259
+ mode: this.widgetMode
260
+ });
261
+
262
+ if (cw) {
263
+ if (!welcomeText && this.welcomeChipsData.length === 0) return;
264
+
265
+ // Only add if not already present in session (to avoid duplicates on refresh)
266
+ const session = this.sessionManager?.getSession();
267
+ const alreadyExists = session?.messages.some(m => m.content === welcomeText && m.role === role);
268
+
269
+ if (!alreadyExists) {
270
+ if (welcomeText) {
271
+ cw.addMessage(role, welcomeText);
272
+ this.sessionManager?.pushMessage(role, welcomeText);
273
+ }
274
+
275
+ if (this.welcomeChipsData.length > 0) {
276
+ cw.addButtons(this.welcomeChipsData);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ private async initializeChatSession(forceNew: boolean = false): Promise<void> {
283
+ if (!this.sessionActive) return;
284
+ if (this.chatInitialized) return;
285
+ this.chatInitialized = true;
286
+
287
+ try {
288
+ ChatService.setChatUrl(this.chatServerUrl);
289
+
290
+ const session = this.sessionManager?.getSession();
291
+
292
+ // ── Restore existing session ──────────────────────────────────────
293
+ if (!forceNew && session?.prospectId) {
294
+ this.prospectId = session.prospectId;
295
+ this.chatId = session.chatId;
296
+ this.conversationId = session.conversationId || null;
297
+
298
+ console.log(`[VaniraAI] Session restored — tab: ${this.sessionManager?.getTabId()}, prospect: ${this.prospectId}`);
299
+
300
+ // Restore messages into UI
301
+ this.restoreSessionMessages();
302
+
303
+ // Re-attach the SSE admin-reply stream
304
+ if (this.chatId && !this.eventSource) {
305
+ this.eventSource = ChatService.listenForAdminReplies(
306
+ this.chatId,
307
+ this.prospectId,
308
+ (text) => {
309
+ this.currentView?.getChatWindow?.().addMessage('assistant', text);
310
+ this.sessionManager?.pushMessage('assistant', text);
311
+ this.playMessageSound('receive');
312
+ }
313
+ );
314
+ }
315
+
316
+ return; // Don't fetch a new welcome message
317
+ }
318
+
319
+ // ── Fresh session ─────────────────────────────────────────────────
320
+ if (!this.prospectId) {
321
+ if (this.prospectGroupId) {
322
+ this.prospectId = await ChatService.createChatProspect(this.prospectGroupId);
323
+ } else {
324
+ this.prospectId = `anon_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
325
+ }
326
+ }
327
+
328
+ const welcome = await ChatService.fetchWelcomeMessage(this.agentId, this.prospectId, this.widgetId || undefined, this.pkKey || undefined);
329
+
330
+ if (welcome) {
331
+ // Ensure the welcome message is rendered in the chat window
332
+ this.handleWelcomePayload(welcome);
333
+
334
+ if (welcome.chatId && !this.chatId) {
335
+ this.chatId = welcome.chatId;
336
+ if (!this.eventSource) {
337
+ this.eventSource = ChatService.listenForAdminReplies(
338
+ this.chatId,
339
+ this.prospectId,
340
+ (text) => {
341
+ this.currentView?.getChatWindow?.().addMessage('assistant', text);
342
+ this.sessionManager?.pushMessage('assistant', text);
343
+ this.playMessageSound('receive');
344
+ }
345
+ );
346
+ }
347
+ }
348
+ if (welcome.conversationId) {
349
+ this.conversationId = welcome.conversationId;
350
+ }
351
+ }
352
+
353
+ // Persist IDs so a refresh restores the session
354
+ this.sessionManager?.saveIds(this.prospectId, this.chatId, this.conversationId);
355
+ } catch (error) {
356
+ console.error('[VaniraAI] Chat init failed', error);
357
+ const fallback = 'Hey! how can I help you ?';
358
+ this.currentView?.getChatWindow?.().addMessage('assistant', fallback);
359
+ this.sessionManager?.pushMessage('assistant', fallback);
360
+ }
361
+ }
362
+
363
+ async initialize(config: any): Promise<void> {
364
+ await super.initialize(config);
365
+ this.processConfig(config);
366
+ if (this.root) {
367
+ this.ui_renderer(this.root);
368
+ }
369
+ }
370
+
371
+ private processConfig(config: any) {
372
+ this.widgetId = config.widgetId || config.widget_id || this.widgetId || '';
373
+ this.agentId = config.agentId || config.agent_id || this.agentId || '';
374
+ this.prospectGroupId = config.prospectGroupId || config.client?.base_prospect_group_id || this.prospectGroupId || '';
375
+ this.widgetMode = config.widgetMode || config.mode || this.widgetMode || 'voice_only';
376
+ this.pkKey = config.pkKey || config.pk_key || config.publicKey || config.public_key || this.pkKey || '';
377
+
378
+ this.primaryColor = config.primaryColor || config.primary_color || '#000000';
379
+ this.secondaryColor = config.secondaryColor || config.secondary_color || '#111111';
380
+ this.gradient = config.gradient || null;
381
+ this.position = config.position || 'bottom-right';
382
+ this.positionType = config.positionType || 'fixed';
383
+ this.widgetIcon = config.widgetIcon || config.icon || null;
384
+ this.iconConfig = config.iconConfig || config.icon_config || null;
385
+ this.chatWelcomeMessage = config.chatWelcomeMessage || config.chat_welcome_message || config.agent?.chat_welcome_message || null;
386
+
387
+ console.log('[VaniraAI] processConfig:', {
388
+ hasPrimary: !!this.primaryColor,
389
+ hasSecondary: !!this.secondaryColor,
390
+ hasWelcome: !!this.chatWelcomeMessage
391
+ });
392
+
393
+ // Try to parse if it's a string
394
+ if (typeof this.chatWelcomeMessage === 'string') {
395
+ try {
396
+ this.chatWelcomeMessage = JSON.parse(this.chatWelcomeMessage);
397
+ } catch (e) {
398
+ console.warn('[VaniraAI] Failed to parse chatWelcomeMessage string', e);
399
+ }
400
+ }
401
+
402
+ // Configurable server URLs — fall back to Vanira production endpoints
403
+ this.chatServerUrl = config.serverUrl || config.chatServerUrl || 'https://inboxapi.vanira.io';
404
+
405
+ this.updateWelcomeChipsVisibility();
406
+ }
407
+
408
+ async create_call(behavior: 'continue' | 'new' = 'continue'): Promise<void> {
409
+ if (this.callActive || !this.agentId) return;
410
+ if (!this.sessionActive) {
411
+ this.sessionManager?.forceClaimSession();
412
+ this.sessionActive = true;
413
+ }
414
+ if (behavior === 'new') {
415
+ this.presetRenderer?.resetChalkboard();
416
+ }
417
+ try {
418
+ this.callActive = true;
419
+ this.updateViewCallState(true);
420
+ this.updateViewStatus('connecting');
421
+
422
+ const customUrl = this.config?.serverUrl || this.config?.chatServerUrl;
423
+ const backendUrl = customUrl && !customUrl.includes('travelr.club') ? customUrl : 'https://api.vanira.io';
424
+
425
+ // Use VaniraAI SDK — handles pre-flight, storage, and session continuity automatically!
426
+ const client = new VaniraAI({
427
+ agentId: this.agentId,
428
+ apiKey: this.pkKey || undefined,
429
+ sessionBehavior: behavior,
430
+ prospectId: this.prospectId || undefined,
431
+ backendUrl,
432
+ });
433
+
434
+ // Synchronize unified prospect identity between voice and chat channels
435
+ client.on('session_started', ({ prospectId }) => {
436
+ if (prospectId) {
437
+ this.prospectId = prospectId;
438
+ this.sessionManager?.saveIds(prospectId, this.chatId);
439
+ }
440
+ });
441
+
442
+ // Status events
443
+ client
444
+ .on('connected', () => this.updateViewStatus('connected'))
445
+ .on('disconnected', () => this.end_call())
446
+ .on('error', (msg) => {
447
+ console.error('[VaniraAI Widget] Call error:', msg);
448
+ this.updateViewStatus('error', msg);
449
+ })
450
+ .on('transcription', ({ text, isFinal }) => {
451
+ this.currentView?.setTranscription?.(text, isFinal);
452
+ })
453
+ .on('track', ({ track }) => {
454
+ if (this.widgetMode.includes('avatar') && track.kind === 'video' && this.currentView?.setVideoTrack) {
455
+ this.currentView.setVideoTrack(track);
456
+ }
457
+ })
458
+ .on('tool_call', (toolCall) => {
459
+ console.log('[VaniraAI Widget] Tool call received:', toolCall.name, toolCall.arguments);
460
+
461
+ // ── Try preset renderer first ────────────────────────────
462
+ const handled = this.presetRenderer?.handle(
463
+ toolCall,
464
+ (result) => {
465
+ // onComplete — wake AI with triggerInterrupt (sends action + wakes TTS)
466
+ const args = toolCall.arguments || (toolCall as any).args || {};
467
+ let parsedArgs = args;
468
+ if (typeof args === 'string') {
469
+ try { parsedArgs = JSON.parse(args); } catch (_) { }
470
+ }
471
+ const presetId = (toolCall as any).client_fields?.preset_id || parsedArgs?.preset_id;
472
+
473
+ // If it's a typing or erase preset, don't send back anything to the AI
474
+ if (presetId === 'vanira_type_text' || presetId === 'vanira_erase_text') {
475
+ console.log(`[VaniraInternalProvider] Preset ${presetId}: skipping triggerInterrupt to prevent sending anything back to the AI.`);
476
+ return;
477
+ }
478
+
479
+ // Skip client action trigger if client_media_update already happened (result has media_id)
480
+ if (result && (result.media_id || result.url)) {
481
+ console.log('[VaniraInternalProvider] Skipping triggerInterrupt because media was already updated.');
482
+ return;
483
+ }
484
+
485
+ const actionName = ((toolCall as any).client_fields?.action_name || (presetId === 'vanira_calendar' ? 'calendar_slot_selected' : `user_submitted_${toolCall.name}`)) as string;
486
+ client.triggerInterrupt(actionName, result);
487
+ },
488
+ (_reason) => {
489
+ // onCancel — send cancelled status so AI isn't left hanging
490
+ const args = toolCall.arguments || (toolCall as any).args || {};
491
+ let parsedArgs = args;
492
+ if (typeof args === 'string') {
493
+ try { parsedArgs = JSON.parse(args); } catch (_) { }
494
+ }
495
+ const presetId = (toolCall as any).client_fields?.preset_id || parsedArgs?.preset_id;
496
+
497
+ // If it's a typing or erase preset, don't send back anything to the AI
498
+ if (presetId === 'vanira_type_text' || presetId === 'vanira_erase_text') {
499
+ console.log(`[VaniraInternalProvider] Preset ${presetId}: skipping sendToolResult onCancel.`);
500
+ return;
501
+ }
502
+
503
+ const toolCallId = toolCall.tool_call_id;
504
+ try { client.sendToolResult(toolCallId, { status: 'cancelled', reason: 'User dismissed' }); } catch (_) { }
505
+ },
506
+ client // pass client so camera/upload can call uploadMedia()
507
+ );
508
+ if (handled) return;
509
+
510
+ // ── Not a preset — dispatch to host page ─────────────────
511
+ const event = new CustomEvent('vaniraai:tool_call', {
512
+ detail: toolCall,
513
+ bubbles: true,
514
+ composed: true,
515
+ });
516
+ this.root?.host?.dispatchEvent(event);
517
+ });
518
+
519
+ this.vaniraClient = client;
520
+ await client.start();
521
+
522
+ } catch (err: any) {
523
+ console.error('[VaniraAI Widget] Call failed', err);
524
+ this.updateViewStatus('error', err?.message || String(err));
525
+ }
526
+ }
527
+
528
+ end_call(): void {
529
+ this.callActive = false;
530
+ const client = this.vaniraClient;
531
+ this.vaniraClient = null;
532
+ if (client) {
533
+ client.stop();
534
+ }
535
+ this.presetRenderer?.dismiss(); // close any open preset when call ends
536
+ this.updateViewCallState(false);
537
+ }
538
+
539
+ private updateViewCallState(active: boolean) {
540
+ if (this.currentView?.setCallActive) {
541
+ this.currentView.setCallActive(active);
542
+ }
543
+ }
544
+
545
+ private updateViewStatus(status: 'connecting' | 'connected' | 'disconnected' | 'error', errorMsg?: string) {
546
+ if (this.currentView?.setStatus) {
547
+ this.currentView.setStatus(status, errorMsg);
548
+ }
549
+ }
550
+
551
+ ui_renderer(root: ShadowRoot): void {
552
+ this.root = root;
553
+ this.root.innerHTML = ''; // Start fresh
554
+ this.presetRenderer = new WidgetPresetRenderer(root);
555
+
556
+ // Apply portal class to host for visibility if mode is fixed
557
+ if (this.root.host) {
558
+ if (this.positionType === 'fixed') {
559
+ this.root.host.classList.add('vanira-portal');
560
+ } else {
561
+ this.root.host.classList.remove('vanira-portal');
562
+ }
563
+ }
564
+
565
+ // ── Session Management ──────────────────────────────────────────────
566
+ // Inject keyframe animation for the conflict banner
567
+ const animStyle = document.createElement('style');
568
+ animStyle.textContent = `@keyframes vanira-slide-up {
569
+ from { opacity:0; transform:translateY(16px); }
570
+ to { opacity:1; transform:translateY(0); }
571
+ }`;
572
+ this.root.appendChild(animStyle);
573
+
574
+ // Establish the design tokens
575
+ const style = document.createElement('style');
576
+ style.textContent = `
577
+ ${baseCss}
578
+ :host { ${generateThemeVars(this.primaryColor, this.secondaryColor)} }
579
+ ${FloatingButton.styles}
580
+ ${FloatingWelcomeChips.styles}
581
+ ${Panel.styles}
582
+ ${this.gradient ? `.widget-fab { background: ${this.gradient} !important; }` : ''}
583
+ .clean-card-launcher {
584
+ position: fixed;
585
+ z-index: 9999;
586
+ bottom: 20px;
587
+ right: 20px;
588
+ background: #ffffff;
589
+ border-radius: 14px;
590
+ padding: 12px 14px 10px;
591
+ box-shadow: 0 6px 28px rgba(0,0,0,0.15), 0 2px 6px rgba(0,0,0,0.08);
592
+ display: flex;
593
+ flex-direction: column;
594
+ gap: 8px;
595
+ min-width: 180px;
596
+ max-width: 230px;
597
+ cursor: pointer;
598
+ pointer-events: auto !important;
599
+ animation: fadeInScale 0.35s cubic-bezier(0.34,1.56,0.64,1);
600
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
601
+ }
602
+ .clean-card-logo {
603
+ font-size: 8px;
604
+ font-weight: 800;
605
+ letter-spacing: 0.18em;
606
+ text-transform: uppercase;
607
+ color: rgba(0,0,0,0.28);
608
+ line-height: 1;
609
+ }
610
+ .clean-card-title {
611
+ font-size: 12px;
612
+ font-weight: 700;
613
+ color: #0f172a;
614
+ line-height: 1.3;
615
+ margin: 1px 0 0;
616
+ }
617
+ .clean-card-btn {
618
+ display: flex;
619
+ align-items: center;
620
+ justify-content: center;
621
+ gap: 5px;
622
+ width: 100%;
623
+ padding: 7px 12px;
624
+ border-radius: 8px;
625
+ background: #0f172a;
626
+ color: #ffffff;
627
+ font-size: 10px;
628
+ font-weight: 700;
629
+ letter-spacing: 0.05em;
630
+ text-transform: uppercase;
631
+ border: none;
632
+ cursor: pointer;
633
+ box-shadow: 0 2px 6px rgba(0,0,0,0.15);
634
+ transition: background 0.2s, transform 0.15s;
635
+ }
636
+ .clean-card-btn:hover {
637
+ background: #1e293b;
638
+ transform: scale(1.02);
639
+ }
640
+ .clean-card-btn svg {
641
+ width: 12px;
642
+ height: 12px;
643
+ fill: currentColor;
644
+ }
645
+ `;
646
+ this.root.appendChild(style);
647
+
648
+ // Load saved position
649
+ const posKey = `vanira_pos_${this.widgetId || this.agentId}`;
650
+ const savedPos = localStorage.getItem(posKey);
651
+ let coords: { x: number; y: number } | null = null;
652
+ if (savedPos) {
653
+ try { coords = JSON.parse(savedPos); } catch (e) { }
654
+ }
655
+
656
+ // FloatingButton
657
+ this.floatingButton = new FloatingButton(
658
+ () => this.togglePanel(),
659
+ (x, y) => {
660
+ // Sync chips
661
+ this.floatingWelcomeChips?.updateCoordinates(x, y, y > window.innerHeight / 2);
662
+ // Sync panel if open
663
+ if (this.isPanelOpen) {
664
+ this.panel?.updatePosition({
665
+ left: `${Math.max(20, Math.min(x - 170, window.innerWidth - 420))}px`,
666
+ top: y > window.innerHeight / 2 ? 'auto' : `${y + 80}px`,
667
+ bottom: y > window.innerHeight / 2 ? `${window.innerHeight - y + 20}px` : 'auto'
668
+ });
669
+ }
670
+ },
671
+ (x, y) => {
672
+ localStorage.setItem(posKey, JSON.stringify({ x, y }));
673
+ }
674
+ );
675
+ this.floatingButton.setIcon(this.getFabIcon());
676
+
677
+ // Initial Positioning
678
+ if (coords) {
679
+ this.floatingButton.updateCoordinates(coords.x, coords.y);
680
+ } else {
681
+ this.floatingButton.setPosition(this.getPosStyle());
682
+ }
683
+
684
+ // Floating Welcome Chips
685
+ this.floatingWelcomeChips = new FloatingWelcomeChips((text) => this.handleFloatingChipClick(text));
686
+ if (coords) {
687
+ this.floatingWelcomeChips.updateCoordinates(coords.x, coords.y, coords.y > window.innerHeight / 2);
688
+ } else {
689
+ this.floatingWelcomeChips.setPosition(this.getPosStyle());
690
+ }
691
+
692
+ // Panel
693
+ this.panel = new Panel(() => this.closePanel(), this.getPanelTitle());
694
+
695
+ // Append components
696
+ this.root.appendChild(this.floatingWelcomeChips.getElement());
697
+
698
+ // ── Clean Card Launcher (replaces FAB when widgetIcon === 'clean_assistant') ──
699
+ if (this.widgetIcon === 'clean_assistant') {
700
+ const logoText = this.iconConfig?.logo_text || 'vanira';
701
+ const title = this.iconConfig?.title || 'Vanira Voice AI Assistant';
702
+ const buttonText = this.iconConfig?.button_text || 'Begin Call';
703
+
704
+ const hasSavedSession = typeof window !== 'undefined' &&
705
+ ((window.sessionStorage.getItem('vanira_prospect_id') || window.localStorage.getItem('vanira_prospect_id')) &&
706
+ (window.sessionStorage.getItem('vanira_latest_call_id') || window.localStorage.getItem('vanira_latest_call_id')));
707
+
708
+ let selectedBehavior: 'continue' | 'new' = 'continue';
709
+
710
+ const card = document.createElement('div');
711
+ card.className = 'clean-card-launcher';
712
+ card.innerHTML = `
713
+ <div>
714
+ <div class="clean-card-logo">${logoText}</div>
715
+ <div class="clean-card-title">${title}</div>
716
+ </div>
717
+ ${hasSavedSession ? `
718
+ <div class="session-selector-container">
719
+ <div class="session-option active" data-behavior="continue">
720
+ <span class="session-title">Resume</span>
721
+ </div>
722
+ <div class="session-option" data-behavior="new">
723
+ <span class="session-title">Start Fresh</span>
724
+ </div>
725
+ </div>
726
+ ` : ''}
727
+ <button class="clean-card-btn">
728
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
729
+ <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.14 12 19.79 19.79 0 0 1 1.07 3.4 2 2 0 0 1 3 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 8.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>
730
+ </svg>
731
+ ${buttonText}
732
+ </button>
733
+ `;
734
+
735
+ const options = card.querySelectorAll('.session-option');
736
+ options.forEach(opt => {
737
+ opt.addEventListener('click', (e) => {
738
+ e.stopPropagation(); // Avoid launching card action on selection change
739
+ options.forEach(o => o.classList.remove('active'));
740
+ opt.classList.add('active');
741
+ selectedBehavior = opt.getAttribute('data-behavior') as 'continue' | 'new';
742
+ });
743
+ });
744
+
745
+ const handleCardClick = (e: Event) => {
746
+ e.stopPropagation();
747
+ card.style.display = 'none';
748
+ this.openPanel();
749
+ this.create_call(selectedBehavior);
750
+ };
751
+ card.addEventListener('click', handleCardClick);
752
+ const btn = card.querySelector('.clean-card-btn') as HTMLButtonElement;
753
+ btn?.addEventListener('click', (e) => {
754
+ e.stopPropagation();
755
+ handleCardClick(e);
756
+ });
757
+
758
+ this.root.appendChild(card);
759
+ } else {
760
+ // Standard FAB
761
+ this.root.appendChild(this.floatingButton.getElement());
762
+ }
763
+
764
+ this.root.appendChild(this.panel.getElement());
765
+
766
+ // Setup content view
767
+ this.setupPanelContent();
768
+
769
+ // ── Render Welcome (Stateless/Instant) ──────────────────────────────
770
+ // Skip static welcome in chat modes — initializeChatSession() fetches a
771
+ // fresh one from the API. Showing both causes the message to appear twice.
772
+ if (this.chatWelcomeMessage && !this.widgetMode.includes('chat')) {
773
+ this.handleWelcomePayload(this.chatWelcomeMessage);
774
+ }
775
+
776
+ // ── Init Session ────────────────────────────────────────────────────
777
+ const owned = this.initSessionManager();
778
+
779
+ if (!owned) {
780
+ // Another live tab owns the session — show a conflict banner
781
+ this.showTabConflictBanner('conflict');
782
+ } else if (this.widgetMode.includes('chat')) {
783
+ // Session is ours — initialise chat (will restore if session exists)
784
+ this.initializeChatSession();
785
+ }
786
+ }
787
+
788
+ private getFabIcon(): string {
789
+ if (this.widgetIcon && widgetIcons[this.widgetIcon]) {
790
+ return widgetIcons[this.widgetIcon];
791
+ }
792
+ if (this.widgetMode.includes('chat')) {
793
+ return icons.chat;
794
+ }
795
+ return icons.voice_orb;
796
+ }
797
+
798
+ private getPanelTitle(): string {
799
+ return 'Assistant';
800
+ }
801
+
802
+ private getPosStyle() {
803
+ const posStyles: Record<string, string> = {
804
+ 'bottom-right': 'bottom: 24px; right: 24px;',
805
+ 'bottom-left': 'bottom: 24px; left: 24px;',
806
+ 'top-right': 'top: 24px; right: 24px;',
807
+ 'top-left': 'top: 24px; left: 24px;',
808
+ };
809
+ return posStyles[this.position] || posStyles['bottom-right'];
810
+ }
811
+
812
+ private setupPanelContent() {
813
+ // ── Voice Call History callback ──────────────────────────────────────────
814
+ const onCallEnded = (durationMs: number, startedAt: number) => {
815
+ // 1. Inject card into active chat window (chat_voice / chat_avatar modes)
816
+ const cw = this.currentView?.getChatWindow?.();
817
+ if (cw) cw.addCallRecord(durationMs, startedAt);
818
+
819
+ // 2. Persist to localStorage for cross-session restore
820
+ const key = `vanira_calls_${this.widgetId || this.agentId}`;
821
+ const existing: Array<{ durationMs: number; startedAt: number }> = JSON.parse(localStorage.getItem(key) || '[]');
822
+ existing.push({ durationMs, startedAt });
823
+ // Keep last 50 records max
824
+ if (existing.length > 50) existing.splice(0, existing.length - 50);
825
+ localStorage.setItem(key, JSON.stringify(existing));
826
+ };
827
+
828
+ switch (this.widgetMode) {
829
+ case 'chat_only':
830
+ this.currentView = new ChatOnlyView((msg: string) => this.handleChatSend(msg));
831
+ break;
832
+ case 'chat_voice':
833
+ this.currentView = new ChatVoiceView(
834
+ (msg: string) => this.handleChatSend(msg),
835
+ () => this.create_call(),
836
+ () => this.end_call(),
837
+ this.primaryColor, this.secondaryColor,
838
+ onCallEnded
839
+ );
840
+ break;
841
+ case 'avatar_only':
842
+ this.currentView = new AvatarOnlyView(
843
+ () => this.create_call(),
844
+ () => this.end_call(),
845
+ this.primaryColor, this.secondaryColor,
846
+ onCallEnded
847
+ );
848
+ break;
849
+ case 'chat_avatar':
850
+ this.currentView = new ChatAvatarView(
851
+ (msg: string) => this.handleChatSend(msg),
852
+ () => this.create_call(),
853
+ () => this.end_call(),
854
+ this.primaryColor, this.secondaryColor,
855
+ onCallEnded
856
+ );
857
+ break;
858
+ case 'voice_only':
859
+ default:
860
+ this.currentView = new VoiceOnlyView(
861
+ (behavior) => this.create_call(behavior),
862
+ () => this.end_call(),
863
+ this.primaryColor, this.secondaryColor,
864
+ onCallEnded
865
+ );
866
+ break;
867
+ }
868
+
869
+ if (this.currentView && this.panel) {
870
+ this.panel.setContent(this.currentView.getElement());
871
+
872
+ if (this.widgetMode === 'voice_only') {
873
+ this.panel.getElement().classList.add('voice-only-panel');
874
+ } else {
875
+ this.panel.getElement().classList.remove('voice-only-panel');
876
+ }
877
+
878
+ const cw = this.currentView.getChatWindow?.();
879
+ if (cw) {
880
+ cw.setResolveCallback(() => this.resolveActiveConversation());
881
+ }
882
+ }
883
+ }
884
+
885
+ private async handleChatSend(text: string) {
886
+ if (!text) return;
887
+ if (!this.sessionActive) {
888
+ this.sessionManager?.forceClaimSession();
889
+ this.sessionActive = true;
890
+ if (this.widgetMode.includes('chat')) {
891
+ await this.initializeChatSession(false);
892
+ }
893
+ }
894
+
895
+ const cw = this.currentView?.getChatWindow?.();
896
+ if (!cw) return;
897
+
898
+ // Ensure SSE stream is running
899
+ if (this.chatId && !this.eventSource) {
900
+ this.eventSource = ChatService.listenForAdminReplies(
901
+ this.chatId,
902
+ this.prospectId,
903
+ (replyText) => {
904
+ this.currentView?.getChatWindow?.().addMessage('assistant', replyText);
905
+ this.sessionManager?.pushMessage('assistant', replyText);
906
+ this.playMessageSound('receive');
907
+ }
908
+ );
909
+ }
910
+
911
+ cw.addMessage('user', text);
912
+ this.sessionManager?.pushMessage('user', text);
913
+ cw.setTyping(true);
914
+
915
+ // Push an empty assistant placeholder BEFORE streaming starts.
916
+ // This ensures updateLastAssistantMessage always targets this new slot,
917
+ // not the previous welcome message.
918
+ this.sessionManager?.pushMessage('assistant', '');
919
+
920
+ try {
921
+ let hasPlayedReceive = false;
922
+
923
+ await this.chatAdapter.sendMessage(
924
+ text,
925
+ this.agentId,
926
+ this.prospectId,
927
+ this.chatId,
928
+ (response) => {
929
+ if (!hasPlayedReceive) {
930
+ this.playMessageSound('receive');
931
+ hasPlayedReceive = true;
932
+ }
933
+ // onWidget
934
+ cw.setTyping(false);
935
+ if (response?.type === 'button_list' && response.data?.buttons) {
936
+ cw.addButtons(response.data.buttons);
937
+ this.welcomeChipsData = response.data.buttons;
938
+ this.updateWelcomeChipsVisibility();
939
+ }
940
+ },
941
+ (streamText) => {
942
+ if (!hasPlayedReceive) {
943
+ this.playMessageSound('receive');
944
+ hasPlayedReceive = true;
945
+ }
946
+ // onStream — update last assistant message
947
+ cw.setTyping(false);
948
+ cw.updateLastAssistantMessage(streamText);
949
+ this.sessionManager?.updateLastAssistantMessage(streamText);
950
+ },
951
+ (newId, newConversationId) => {
952
+ if (newId) {
953
+ this.chatId = newId;
954
+ }
955
+ if (newConversationId) {
956
+ this.conversationId = newConversationId;
957
+ }
958
+ if (newId || newConversationId) {
959
+ this.sessionManager?.saveIds(this.prospectId, this.chatId, this.conversationId);
960
+ }
961
+ },
962
+ this.widgetId || undefined,
963
+ this.pkKey || undefined
964
+ );
965
+ } catch (err) {
966
+ cw.setTyping(false);
967
+ console.error('[VaniraAI] Send message failed', err);
968
+ cw.addMessage('assistant', 'Error sending message.');
969
+ }
970
+ }
971
+
972
+ // ─── Panel Management ─────────────────────────────────────────────────────
973
+
974
+ private togglePanel() {
975
+ this.isPanelOpen ? this.closePanel() : this.openPanel();
976
+ }
977
+
978
+ private openPanel() {
979
+ this.isPanelOpen = true;
980
+ this.updateWelcomeChipsVisibility();
981
+
982
+ const rect = this.floatingButton?.getElement().getBoundingClientRect();
983
+ const hasRect = rect && rect.width > 0;
984
+
985
+ if (hasRect) {
986
+ const isBottom = rect.top > window.innerHeight / 2;
987
+ this.panel?.open({
988
+ left: `${Math.max(20, Math.min(rect.left - 170, window.innerWidth - 420))}px`,
989
+ bottom: isBottom ? `${window.innerHeight - rect.top + 20}px` : undefined,
990
+ top: !isBottom ? `${rect.top + 80}px` : undefined,
991
+ });
992
+ } else {
993
+ // Fallback for clean_assistant or detached FAB
994
+ this.panel?.open({
995
+ bottom: '90px',
996
+ right: '20px',
997
+ left: 'auto',
998
+ top: 'auto',
999
+ });
1000
+ }
1001
+
1002
+ if (this.root?.host) {
1003
+ this.root.host.classList.add('vanira-panel-open');
1004
+ }
1005
+ }
1006
+
1007
+ private closePanel() {
1008
+ this.isPanelOpen = false;
1009
+ this.updateWelcomeChipsVisibility();
1010
+ this.panel?.close();
1011
+ if (this.root?.host) {
1012
+ this.root.host.classList.remove('vanira-panel-open');
1013
+ }
1014
+ }
1015
+
1016
+ private updateWelcomeChipsVisibility() {
1017
+ if (!this.floatingWelcomeChips) return;
1018
+
1019
+ const shouldShow = !this.isPanelOpen && this.welcomeChipsData.length > 0;
1020
+
1021
+ console.log('[VaniraAI] updateWelcomeChipsVisibility:', {
1022
+ visible: shouldShow,
1023
+ isPanelOpen: this.isPanelOpen,
1024
+ chipsCount: this.welcomeChipsData.length
1025
+ });
1026
+
1027
+ if (shouldShow) {
1028
+ this.floatingWelcomeChips.setChips(this.welcomeChipsData);
1029
+ this.floatingWelcomeChips.getElement().style.display = 'flex';
1030
+ } else {
1031
+ this.floatingWelcomeChips.getElement().style.display = 'none';
1032
+ }
1033
+ }
1034
+
1035
+ private handleFloatingChipClick(text: string) {
1036
+ this.openPanel();
1037
+ this.handleChatSend(text);
1038
+ }
1039
+
1040
+ private async resolveActiveConversation() {
1041
+ if (this.eventSource) {
1042
+ this.eventSource.close();
1043
+ this.eventSource = null;
1044
+ }
1045
+
1046
+ if (this.conversationId) {
1047
+ try {
1048
+ await ChatService.resolveConversation(this.conversationId);
1049
+ } catch (e) {
1050
+ console.error('[VaniraAI] Failed to resolve conversation on backend:', e);
1051
+ }
1052
+ } else {
1053
+ console.warn('[VaniraAI] Cannot resolve — conversation_id not yet received from /widget/chat');
1054
+ }
1055
+
1056
+ // Reset local session and restart fresh
1057
+ this.sessionManager?.clearChatKeepProspect();
1058
+ this.conversationId = null;
1059
+ this.chatId = null;
1060
+ const cw = this.currentView?.getChatWindow?.();
1061
+ if (cw) cw.clearMessages();
1062
+
1063
+ this.chatInitialized = false;
1064
+ await this.initializeChatSession(true);
1065
+ }
1066
+ }