@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.
- package/README.md +239 -0
- package/package.json +53 -0
- package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
- package/src/__tests__/adapters.test.ts +475 -0
- package/src/__tests__/httpResponse.test.ts +25 -0
- package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
- package/src/__tests__/mocks/react-native-permissions.ts +15 -0
- package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
- package/src/__tests__/mocks/react-native.ts +28 -0
- package/src/__tests__/preset.test.ts +239 -0
- package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
- package/src/__tests__/storage.test.ts +211 -0
- package/src/__tests__/webrtcSignaling.test.ts +42 -0
- package/src/adapters/PeerConnectionAdapter.ts +101 -0
- package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
- package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
- package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
- package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
- package/src/adapters/browser/index.ts +4 -0
- package/src/adapters/interfaces.ts +84 -0
- package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
- package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
- package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
- package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
- package/src/adapters/react-native/callAudioRouting.ts +115 -0
- package/src/adapters/react-native/decodeUtf8.ts +72 -0
- package/src/adapters/react-native/index.ts +4 -0
- package/src/adapters/react-native/rnUploadFile.ts +76 -0
- package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
- package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
- package/src/adapters/storage/StorageAdapter.ts +21 -0
- package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
- package/src/adapters/storage/index.ts +7 -0
- package/src/api/services/ChatService.ts +304 -0
- package/src/api/services/ConfigService.ts +33 -0
- package/src/assets/icons.js +35 -0
- package/src/cdn.ts +68 -0
- package/src/core/CallSessionStore.ts +137 -0
- package/src/core/DraggableController.ts +83 -0
- package/src/core/SessionManager.ts +322 -0
- package/src/core/VaniraAI.ts +464 -0
- package/src/core/WebRTCClient.ts +1012 -0
- package/src/core/httpResponse.ts +22 -0
- package/src/core/iceServers.ts +18 -0
- package/src/core/toolCallNormalize.ts +80 -0
- package/src/core/voice-client.js +236 -0
- package/src/core/webrtcSignaling.ts +72 -0
- package/src/index.js +34 -0
- package/src/index.ts +6 -0
- package/src/platforms/browser.ts +67 -0
- package/src/platforms/react-native.ts +105 -0
- package/src/presets/BookingCalendarModal.tsx +457 -0
- package/src/presets/CameraModal.tsx +576 -0
- package/src/presets/DynamicFormModal.tsx +378 -0
- package/src/presets/NativePresetRenderer.tsx +350 -0
- package/src/presets/NavigateHandler.tsx +75 -0
- package/src/presets/PresetHost.tsx +155 -0
- package/src/presets/PresetShellModal.tsx +97 -0
- package/src/presets/UploadModal.tsx +321 -0
- package/src/presets/calendar/calendarUtils.ts +386 -0
- package/src/presets/call/CallSpeakerToggle.tsx +59 -0
- package/src/presets/call/callAudioRouting.ts +2 -0
- package/src/presets/call/useCallSpeaker.ts +31 -0
- package/src/presets/camera/cameraPermissions.ts +18 -0
- package/src/presets/camera/cameraStream.ts +19 -0
- package/src/presets/camera/cameraUtils.ts +21 -0
- package/src/presets/camera/useLivenessFlow.ts +95 -0
- package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
- package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
- package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
- package/src/presets/chalkboard/boardAbort.ts +36 -0
- package/src/presets/chalkboard/boardQueue.ts +620 -0
- package/src/presets/chalkboard/chalkboardSession.ts +75 -0
- package/src/presets/chalkboard/drawUtils.ts +123 -0
- package/src/presets/chalkboard/textUtils.ts +109 -0
- package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
- package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
- package/src/presets/form/formValidation.ts +104 -0
- package/src/presets/form/parseFormFields.ts +171 -0
- package/src/presets/host/HostElementPresetHandler.tsx +155 -0
- package/src/presets/host/hostPresetBridge.ts +71 -0
- package/src/presets/index.ts +63 -0
- package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
- package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
- package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
- package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
- package/src/presets/liveScreen/liveScreenSession.ts +73 -0
- package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
- package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
- package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
- package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
- package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
- package/src/presets/liveVision/liveVisionSession.ts +75 -0
- package/src/presets/liveVision/liveVisionUpload.ts +62 -0
- package/src/presets/navigation/internalRouteRegistry.ts +25 -0
- package/src/presets/navigation/navigationBridge.ts +76 -0
- package/src/presets/navigation/navigationTypes.ts +12 -0
- package/src/presets/parseToolCall.ts +60 -0
- package/src/presets/presetClientAdapter.ts +29 -0
- package/src/presets/presetCompletion.ts +91 -0
- package/src/presets/presetEventHelpers.ts +45 -0
- package/src/presets/registry.ts +128 -0
- package/src/presets/streaming/mediaFrameUpload.ts +93 -0
- package/src/presets/types.ts +74 -0
- package/src/presets/upload/pickUploadFile.ts +256 -0
- package/src/presets/upload/uploadFormats.ts +163 -0
- package/src/presets/upload/uploadUtils.ts +68 -0
- package/src/react/PresetRenderer.tsx +144 -0
- package/src/react/index.ts +1 -0
- package/src/runtime/browserRuntime.ts +54 -0
- package/src/runtime/platform.ts +17 -0
- package/src/runtime/reactNativeRuntime.ts +68 -0
- package/src/runtime/resolveRuntimeConfig.ts +75 -0
- package/src/runtime/runtimeBundles.ts +74 -0
- package/src/runtime/types.ts +135 -0
- package/src/types/react-native-incall-manager.d.ts +17 -0
- package/src/types/react-native-webrtc.d.ts +47 -0
- package/src/types.ts +133 -0
- package/src/ui/VaniraWidget.ts +87 -0
- package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
- package/src/ui/abstraction/interfaces.ts +12 -0
- package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
- package/src/ui/components/AvatarView.ts +81 -0
- package/src/ui/components/ChatWindow.ts +263 -0
- package/src/ui/components/FloatingButton.ts +163 -0
- package/src/ui/components/FloatingWelcomeChips.ts +137 -0
- package/src/ui/components/Panel.ts +120 -0
- package/src/ui/components/VoiceOrb.ts +79 -0
- package/src/ui/components/VoiceOverlay.ts +497 -0
- package/src/ui/components/index.ts +7 -0
- package/src/ui/factory/WidgetFactory.ts +16 -0
- package/src/ui/icons_data.ts +2 -0
- package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
- package/src/ui/presets/types.ts +16 -0
- package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
- package/src/ui/styles/index.ts +323 -0
- package/src/ui/styles/keyframes.ts +76 -0
- package/src/ui/styles/theme.ts +57 -0
- package/src/ui/styles/widget.css.ts +838 -0
- package/src/ui/utils.ts +37 -0
- package/src/ui/views/AbstractChatView.ts +93 -0
- package/src/ui/views/AbstractVoiceView.ts +57 -0
- package/src/ui/views/AvatarOnlyView.ts +78 -0
- package/src/ui/views/ChatAvatarView.ts +66 -0
- package/src/ui/views/ChatOnlyView.ts +28 -0
- package/src/ui/views/ChatVoiceView.ts +15 -0
- package/src/ui/views/VoiceOnlyView.ts +25 -0
- 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
|
+
}
|