@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,1012 @@
1
+ import { WebRTCClientConfig, ControlEvent, PresetEventPayload } from '../types';
2
+ import { resolveRuntimeConfig } from '../runtime/resolveRuntimeConfig';
3
+ import { canDispatchBrowserEvent } from '../runtime/platform';
4
+ import { normalizeToolCallFields, unwrapToolCallData } from './toolCallNormalize';
5
+ import {
6
+ getIceTrickleUrl,
7
+ ICE_TRICKLE_POLL_MS,
8
+ isWorkerSdpUrl,
9
+ mapIceServersFromCreate,
10
+ type CreateCallResponse,
11
+ } from './webrtcSignaling';
12
+ import type { RuntimeCapabilities } from '../runtime/types';
13
+ import type { AudioAdapter, MediaAdapter, AudioPlayerHandle } from '../adapters/interfaces';
14
+ import type { PeerConnectionAdapter, DataChannelAdapter, DataChannelController } from '../adapters/PeerConnectionAdapter';
15
+ import type { StorageAdapter } from '../adapters/storage/StorageAdapter';
16
+ import {
17
+ isRNUploadFileDescriptor,
18
+ normalizeUploadMimeForUpload,
19
+ type UploadMediaInput,
20
+ uploadFileMimeType,
21
+ } from '../adapters/react-native/rnUploadFile';
22
+ import { readResponseJson } from './httpResponse';
23
+ import {
24
+ activateCallAudioPlayback,
25
+ prepareCallAudioForCapture,
26
+ stopCallAudioSession,
27
+ } from '../adapters/react-native/callAudioRouting';
28
+ import {dispatchBoardAbort} from '../presets/chalkboard/boardAbort';
29
+ import {resetBoardSession} from '../presets/chalkboard/boardQueue';
30
+ import {loadContinueSession, saveCallSession} from './CallSessionStore';
31
+
32
+ const STORAGE_KEY_PROSPECT = 'vanira_prospect_id';
33
+ const STORAGE_KEY_CALL = 'vanira_latest_call_id';
34
+
35
+ function storageValueOrUndefined(value: string | null): string | undefined {
36
+ return value || undefined;
37
+ }
38
+
39
+ export class WebRTCClient {
40
+ public serverUrl: string;
41
+ public agentId: string;
42
+ public callId: string | undefined;
43
+ public prospectId: string | undefined;
44
+ public apiKey: string | undefined;
45
+ private backendUrl: string;
46
+ public token: string | undefined;
47
+ private onConnected: () => void;
48
+ private onDisconnected: () => void;
49
+ private onError: (error: any) => void;
50
+ private onTranscription: (text: string, isFinal: boolean) => void;
51
+ private onLocalStream: (stream: MediaStream) => void;
52
+ // @ts-ignore
53
+ private onRemoteTrack: (track: MediaStreamTrack, stream: MediaStream) => void;
54
+ private onClientToolCall: (toolCall: any) => void;
55
+ private onPreset: ((payload: PresetEventPayload) => void) | undefined;
56
+ private onSessionStarted: ((payload: { prospectId: string; callId: string; serverUrl: string }) => void) | undefined;
57
+ private sessionStartedEmitted: boolean = false;
58
+
59
+ private readonly runtimeName: string;
60
+ private readonly callType: 'web';
61
+ private readonly callIdPrefix: string;
62
+ private readonly capabilities: RuntimeCapabilities;
63
+ private readonly audioAdapter: AudioAdapter;
64
+ private readonly mediaAdapter: MediaAdapter;
65
+ private readonly peerAdapter: PeerConnectionAdapter;
66
+ private readonly dataChannelAdapter: DataChannelAdapter;
67
+ private readonly storage: StorageAdapter;
68
+ private iceServers: RTCIceServer[] = [];
69
+ private readonly sessionBehavior: 'continue' | 'new' | undefined;
70
+
71
+ private pc: RTCPeerConnection | null = null;
72
+ private dataChannel: RTCDataChannel | null = null;
73
+ private dcController: DataChannelController | null = null;
74
+ private audioPlayer: AudioPlayerHandle | null = null;
75
+ private localStream: MediaStream | null = null;
76
+ public connected: boolean = false;
77
+ /** True after onConnected fires — requires transport + DataChannel open. */
78
+ private connectedNotified = false;
79
+ private pendingOutbound: string[] = [];
80
+ private iceTricklePollTimer: ReturnType<typeof setInterval> | null = null;
81
+ private seenRemoteIceCandidates = new Set<string>();
82
+ private playedStreamFallbackTimer: ReturnType<typeof setTimeout> | null = null;
83
+ /** RN has no audio onended — delay after final transcription before playedStream. */
84
+ private static readonly PLAYED_STREAM_AFTER_TRANSCRIPTION_MS = 1800;
85
+ private controlEventTap: ((msg: ControlEvent) => void) | null = null;
86
+ /** Set by disconnect() so in-flight createCall/connect can bail out cleanly. */
87
+ private teardownRequested = false;
88
+
89
+ constructor(config: WebRTCClientConfig) {
90
+ if (!config.agentId) throw new Error('[VaniraSDK] agentId is required');
91
+ if (!config.serverUrl && !config.apiKey) {
92
+ throw new Error('[VaniraSDK] Provide either serverUrl or apiKey (to use createCall())');
93
+ }
94
+
95
+ const resolved = resolveRuntimeConfig(config);
96
+
97
+ this.runtimeName = resolved.runtimeName;
98
+ this.callType = resolved.callType;
99
+ this.callIdPrefix = resolved.callIdPrefix;
100
+ this.capabilities = resolved.capabilities;
101
+ this.audioAdapter = resolved.audio;
102
+ this.mediaAdapter = resolved.media;
103
+ this.peerAdapter = resolved.peer;
104
+ this.dataChannelAdapter = resolved.dataChannel;
105
+ this.storage = resolved.storage;
106
+ if (config.iceServers?.length) {
107
+ console.warn(
108
+ '[VaniraSDK] config.iceServers is deprecated — ICE comes from POST /calls/create only',
109
+ );
110
+ this.iceServers = config.iceServers;
111
+ }
112
+ if (config.serverUrl && config.apiKey) {
113
+ console.warn(
114
+ '[VaniraSDK] config.serverUrl is ignored when apiKey is set — use createCall() worker_url',
115
+ );
116
+ }
117
+ this.sessionBehavior = config.sessionBehavior;
118
+ this.token = config.token;
119
+
120
+ this.agentId = config.agentId;
121
+ this.serverUrl = config.serverUrl ? config.serverUrl.replace(/\/$/, '') : '';
122
+ this.apiKey = config.apiKey;
123
+ this.backendUrl = (config.backendUrl || 'https://api.vanira.io').replace(/\/$/, '');
124
+
125
+ // 1. Resolve prospectId + prior call_id for continue (mirrors web SDK)
126
+ this.prospectId = config.prospectId;
127
+ const isContinue = config.sessionBehavior === 'continue';
128
+
129
+ if (isContinue && !config.callId) {
130
+ const stored = loadContinueSession(this.storage, this.agentId);
131
+ if (stored) {
132
+ this.callId = stored.callId;
133
+ if (!this.prospectId && stored.prospectId) {
134
+ this.prospectId = stored.prospectId;
135
+ }
136
+ }
137
+ }
138
+
139
+ if (!this.prospectId) {
140
+ try {
141
+ const scoped = loadContinueSession(this.storage, this.agentId);
142
+ this.prospectId =
143
+ scoped?.prospectId ||
144
+ storageValueOrUndefined(
145
+ this.storage.getItem(STORAGE_KEY_PROSPECT),
146
+ );
147
+ } catch {
148
+ /* ignore storage errors */
149
+ }
150
+ }
151
+
152
+ // 2. Resolve callId depending on sessionBehavior
153
+ if (config.callId) {
154
+ this.callId = config.callId;
155
+ } else if (!isContinue) {
156
+ this.callId = undefined;
157
+ } else if (!this.callId) {
158
+ try {
159
+ this.callId = storageValueOrUndefined(
160
+ this.storage.getItem(STORAGE_KEY_CALL),
161
+ );
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ }
166
+
167
+ // Callbacks
168
+ this.onConnected = config.onConnected || (() => { });
169
+ this.onDisconnected = config.onDisconnected || (() => { });
170
+ this.onError = config.onError || ((e) => console.error('[WebRTC]', e));
171
+ this.onTranscription = config.onTranscription || (() => { });
172
+ this.onLocalStream = config.onLocalStream || (() => { });
173
+ // @ts-ignore
174
+ this.onRemoteTrack = config.onRemoteTrack || (() => { });
175
+ this.onClientToolCall = config.onClientToolCall || (() => { });
176
+ this.onPreset = config.onPreset;
177
+ this.onSessionStarted = config.onSessionStarted;
178
+ }
179
+
180
+ /**
181
+ * Create a call session via the Vanira API, then connect.
182
+ * Requires apiKey in the constructor config.
183
+ * Equivalent to manually calling POST /calls/create + client.connect().
184
+ */
185
+ private bailIfTeardown(context: string): boolean {
186
+ if (!this.teardownRequested) {
187
+ return false;
188
+ }
189
+ console.log(`[WebRTC] ${context} — aborted (teardown requested)`);
190
+ return true;
191
+ }
192
+
193
+ async createCall(): Promise<void> {
194
+ console.log('[INSTR] WebRTCClient.createCall() entry');
195
+ if (this.bailIfTeardown('createCall() entry')) {
196
+ return;
197
+ }
198
+ if (!this.apiKey) throw new Error("[VaniraAI] apiKey is required to use createCall()");
199
+
200
+ if (this.sessionBehavior === 'new') {
201
+ resetBoardSession();
202
+ }
203
+
204
+ const headers: Record<string, string> = {
205
+ 'Content-Type': 'application/json',
206
+ 'X-API-Key': this.apiKey,
207
+ };
208
+
209
+ const body: Record<string, unknown> = {
210
+ agent_id: this.agentId,
211
+ type: this.callType,
212
+ runtime: this.runtimeName,
213
+ prospect_id: this.prospectId,
214
+ call_id: this.callId,
215
+ };
216
+ if (this.sessionBehavior === 'continue') {
217
+ body.mode = 'continue';
218
+ }
219
+
220
+ const createUrl = `${this.backendUrl}/calls/create`;
221
+ console.log('[INSTR] WebRTCClient.createCall() before fetch', createUrl);
222
+ const response = await fetch(createUrl, {
223
+ method: 'POST',
224
+ headers,
225
+ body: JSON.stringify(body),
226
+ });
227
+ console.log('[INSTR] WebRTCClient.createCall() after fetch');
228
+ console.log('[INSTR] WebRTCClient.createCall() response status', response.status);
229
+ if (this.bailIfTeardown('createCall() after fetch')) {
230
+ return;
231
+ }
232
+
233
+ const data = await readResponseJson<CreateCallResponse>(response, 'POST /calls/create');
234
+ if (this.bailIfTeardown('createCall() after parse')) {
235
+ return;
236
+ }
237
+ if (!response.ok) {
238
+ console.log('[INSTR] WebRTCClient.createCall() response body', JSON.stringify(data));
239
+ const detail = data.detail as { message?: string } | undefined;
240
+ throw new Error(
241
+ `[VaniraAI] createCall failed (${response.status}): ${
242
+ detail?.message || (data.message as string) || response.statusText
243
+ }`,
244
+ );
245
+ }
246
+ console.log('[INSTR] WebRTCClient.createCall() response body', JSON.stringify(data));
247
+ if (!data.call_id || !data.worker_url) {
248
+ throw new Error('[VaniraAI] createCall response missing call_id or worker_url');
249
+ }
250
+
251
+ this.iceServers = mapIceServersFromCreate(data.ice_servers);
252
+ this.callId = data.call_id;
253
+ this.prospectId = data.prospect_id || this.prospectId;
254
+ this.serverUrl = data.worker_url as string;
255
+
256
+ try {
257
+ saveCallSession(
258
+ this.storage,
259
+ this.agentId,
260
+ this.callId || '',
261
+ this.prospectId,
262
+ );
263
+ } catch (e) {
264
+ console.warn('[WebRTC] Failed to save session variables to storage:', e);
265
+ }
266
+
267
+ if (this.onSessionStarted && this.prospectId && this.callId) {
268
+ try {
269
+ this.onSessionStarted({ prospectId: this.prospectId, callId: this.callId, serverUrl: this.serverUrl });
270
+ this.sessionStartedEmitted = true;
271
+ } catch (e) {
272
+ console.error('[WebRTC] Error in onSessionStarted callback:', e);
273
+ }
274
+ }
275
+
276
+ if (this.bailIfTeardown('createCall() before connect')) {
277
+ return;
278
+ }
279
+ await this.connect();
280
+ }
281
+
282
+ /**
283
+ * Connect to the agent. If apiKey is set and serverUrl is not yet resolved,
284
+ * automatically calls the pre-flight API to get call_id + worker_url first.
285
+ */
286
+ async connect(): Promise<void> {
287
+ console.log('[INSTR] WebRTCClient.connect() entry');
288
+ if (this.bailIfTeardown('connect() entry')) {
289
+ return;
290
+ }
291
+ if (this.apiKey && !this.serverUrl) {
292
+ await this.createCall();
293
+ return;
294
+ }
295
+ if (this.bailIfTeardown('connect() after createCall')) {
296
+ return;
297
+ }
298
+
299
+ if (!this.serverUrl) throw new Error('[VaniraAI] serverUrl is missing. Provide apiKey or serverUrl.');
300
+
301
+ if (!this.iceServers.length) {
302
+ throw new Error(
303
+ '[VaniraAI] ice_servers missing — call POST /calls/create before connect()',
304
+ );
305
+ }
306
+
307
+ if (!this.callId) {
308
+ this.callId = this.generateCallId();
309
+ }
310
+
311
+ if (this.onSessionStarted && this.prospectId && this.callId && !this.sessionStartedEmitted) {
312
+ try {
313
+ this.onSessionStarted({ prospectId: this.prospectId, callId: this.callId, serverUrl: this.serverUrl });
314
+ this.sessionStartedEmitted = true;
315
+ } catch (e) {
316
+ console.error('[WebRTC] Error in onSessionStarted callback:', e);
317
+ }
318
+ }
319
+
320
+ console.log('🔵 [WebRTC] Starting connection...');
321
+
322
+ try {
323
+ console.log('[INSTR] WebRTCClient.connect() before RTCPeerConnection creation');
324
+ this.pc = this.peerAdapter.create({
325
+ iceServers: this.iceServers,
326
+ iceTransportPolicy: 'all',
327
+ });
328
+ console.log('[INSTR] WebRTCClient.connect() after RTCPeerConnection creation');
329
+ console.log('🧊 [WebRTC] Using ICE servers from /calls/create:', this.iceServers.length);
330
+
331
+ const iceTrickleUrl = getIceTrickleUrl(this.serverUrl, this.agentId, this.callId);
332
+ console.log('[WebRTC] worker_url (POST offer):', this.serverUrl);
333
+ console.log('[WebRTC] ice_trickle_url (GET/POST ICE):', iceTrickleUrl);
334
+ this.bindIceTrickle(this.pc, iceTrickleUrl);
335
+
336
+ // Android: VoIP mode + earpiece before AudioRecord (avoids emulator beep/loopback).
337
+ if (!this.capabilities.supportsAudioEndedEvent) {
338
+ prepareCallAudioForCapture();
339
+ }
340
+
341
+ let stream: MediaStream;
342
+ try {
343
+ console.log('[INSTR] WebRTCClient.connect() before getUserMedia');
344
+ stream = await this.mediaAdapter.getUserAudio({
345
+ echoCancellation: true,
346
+ noiseSuppression: true,
347
+ autoGainControl: true,
348
+ sampleRate: { ideal: 16000 },
349
+ channelCount: 1,
350
+ });
351
+ console.log('[INSTR] WebRTCClient.connect() after getUserMedia');
352
+ if (this.bailIfTeardown('connect() after getUserMedia')) {
353
+ this.mediaAdapter.stopStream(stream);
354
+ return;
355
+ }
356
+ } catch (err: any) {
357
+ if (this.teardownRequested) {
358
+ return;
359
+ }
360
+ console.error('🎤 [WebRTC] Microphone access failed:', err);
361
+ if (
362
+ err.name === 'NotAllowedError' ||
363
+ err.name === 'PermissionDeniedError' ||
364
+ err.message?.includes('Permission denied')
365
+ ) {
366
+ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
367
+ const isIOS = /iPad|iPhone|iPod/.test(ua) ||
368
+ (typeof navigator !== 'undefined' &&
369
+ navigator.platform === 'MacIntel' &&
370
+ navigator.maxTouchPoints > 1);
371
+ let customMsg = "Microphone access denied. Please allow microphone access in your browser settings.";
372
+
373
+ if (isIOS) {
374
+ if (ua.includes('CriOS')) {
375
+ customMsg = "Microphone access blocked. Please enable it in iOS Settings > Chrome > Microphone, then reload the page.";
376
+ } else if (ua.includes('FxiOS')) {
377
+ customMsg = "Microphone access blocked. Please enable it in iOS Settings > Firefox > Microphone, then reload the page.";
378
+ } else {
379
+ customMsg = "Microphone access blocked. Please enable it in iOS Settings > Safari > Microphone (or tap 'aA' > Website Settings > Allow Microphone).";
380
+ }
381
+ }
382
+ const newErr = new Error(customMsg);
383
+ (newErr as any).name = err.name;
384
+ throw newErr;
385
+ }
386
+ throw err;
387
+ }
388
+ console.log('🎤 [WebRTC] Microphone access granted');
389
+ this.localStream = stream;
390
+
391
+ if (!this.pc) {
392
+ console.log('[WebRTC] Connection aborted: peer connection closed during setup');
393
+ this.mediaAdapter.stopStream(stream);
394
+ return;
395
+ }
396
+
397
+ this.onLocalStream(stream);
398
+
399
+ stream.getTracks().forEach(track => {
400
+ this.pc?.addTrack(track, stream);
401
+ });
402
+
403
+ if (!this.pc) {
404
+ throw new Error('RTCPeerConnection was closed unexpectedly');
405
+ }
406
+
407
+ this.dataChannel = this.pc.createDataChannel('control');
408
+ this.dcController = this.dataChannelAdapter.bind(this.dataChannel, {
409
+ onOpen: () => {
410
+ console.log('📡 [WebRTC] DataChannel opened');
411
+ this.flushPendingOutbound();
412
+ this.tryNotifyConnected();
413
+ },
414
+ onMessage: ({ text }) => {
415
+ try {
416
+ this.handleControlEvent(JSON.parse(text));
417
+ } catch {
418
+ console.warn('[WebRTC] Failed to parse message:', text);
419
+ }
420
+ },
421
+ onError: (e) => console.error('❌ [WebRTC] DataChannel error:', e),
422
+ });
423
+
424
+ this.pc.ontrack = (event) => {
425
+ const track = event.track;
426
+ let stream = event.streams?.[0];
427
+ if (!stream && track) {
428
+ // react-native-webrtc may omit event.streams on some Android builds.
429
+ stream = new MediaStream([track]);
430
+ }
431
+ console.log(`📥 [WebRTC] Received ${track.kind} track`);
432
+
433
+ if (track.kind === 'audio') {
434
+ console.log('🔊 [WebRTC] Received audio track from server');
435
+ track.enabled = true;
436
+ this.audioPlayer?.cleanup();
437
+ if (stream) {
438
+ this.audioPlayer = this.audioAdapter.createRemotePlayer(stream);
439
+ }
440
+
441
+ if (this.capabilities.supportsAudioEndedEvent) {
442
+ this.audioPlayer.onEnded(() => {
443
+ this.sendEvent('playedStream');
444
+ console.log('✅ [WebRTC] TTS playback complete');
445
+ });
446
+ }
447
+
448
+ this.onRemoteTrack(track, stream);
449
+ } else if (track.kind === 'video') {
450
+ console.log('📹 [WebRTC] Video track received');
451
+ this.onRemoteTrack(track, stream);
452
+ }
453
+ };
454
+
455
+ const checkConnectionState = () => {
456
+ console.log('🔄 [WebRTC] State:', this.pc?.connectionState, '| ICE:', this.pc?.iceConnectionState);
457
+ const isConnected =
458
+ this.pc?.connectionState === 'connected' ||
459
+ this.pc?.iceConnectionState === 'connected' ||
460
+ this.pc?.iceConnectionState === 'completed';
461
+
462
+ const isFailed =
463
+ this.pc?.connectionState === 'failed' ||
464
+ this.pc?.iceConnectionState === 'failed' ||
465
+ this.pc?.connectionState === 'closed' ||
466
+ this.pc?.iceConnectionState === 'closed' ||
467
+ this.pc?.connectionState === 'disconnected' ||
468
+ this.pc?.iceConnectionState === 'disconnected';
469
+
470
+ if (isConnected && !this.connected) {
471
+ this.connected = true;
472
+ this.tryNotifyConnected();
473
+ } else if (isFailed && this.connected) {
474
+ this.connected = false;
475
+ this.onDisconnected();
476
+ }
477
+ };
478
+
479
+ this.pc.onconnectionstatechange = checkConnectionState;
480
+ this.pc.oniceconnectionstatechange = checkConnectionState;
481
+
482
+ const offer = await this.pc.createOffer();
483
+ await this.pc.setLocalDescription(offer);
484
+ console.log('📝 [WebRTC] Created offer — sending immediately (trickle ICE)');
485
+
486
+ const fetchUrl = this.serverUrl;
487
+ console.log('[WebRTC] Offer POST URL:', fetchUrl);
488
+ if (this.bailIfTeardown('connect() before offer POST')) {
489
+ return;
490
+ }
491
+
492
+ const response = await fetch(fetchUrl, {
493
+ method: 'POST',
494
+ headers: { 'Content-Type': 'application/json' },
495
+ body: JSON.stringify({
496
+ offer: this.pc.localDescription,
497
+ agentId: this.agentId,
498
+ callId: this.callId,
499
+ }),
500
+ });
501
+
502
+ const payload = await readResponseJson<{
503
+ answer?: RTCSessionDescriptionInit;
504
+ error?: string;
505
+ }>(response, 'WebRTC offer exchange');
506
+ if (this.bailIfTeardown('connect() after offer POST')) {
507
+ return;
508
+ }
509
+
510
+ if (!response.ok) {
511
+ throw new Error(payload.error || `WebRTC offer exchange failed (HTTP ${response.status})`);
512
+ }
513
+
514
+ if (!payload.answer) {
515
+ throw new Error(
516
+ `WebRTC offer exchange: response missing answer (HTTP ${response.status})`,
517
+ );
518
+ }
519
+
520
+ console.log('📥 [WebRTC] Received answer from server');
521
+ await this.pc.setRemoteDescription(payload.answer);
522
+ this.startIceTricklePoll(this.pc, iceTrickleUrl);
523
+ console.log('✅ [WebRTC] SDP exchange complete — trickle ICE polling active');
524
+
525
+ } catch (error: any) {
526
+ if (this.teardownRequested) {
527
+ console.log('[WebRTC] Connect aborted during teardown');
528
+ return;
529
+ }
530
+ console.error('❌ [WebRTC] Connection failed:', error);
531
+ this.disconnect();
532
+ this.onError(error.message || error);
533
+ throw error;
534
+ }
535
+ }
536
+
537
+ /** POST local ICE candidates to worker as they are gathered (trickle ICE). */
538
+ private bindIceTrickle(pc: RTCPeerConnection, iceTrickleUrl: string): void {
539
+ pc.onicecandidate = (event) => {
540
+ if (!event.candidate) {
541
+ return;
542
+ }
543
+ fetch(iceTrickleUrl, {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json' },
546
+ body: JSON.stringify({ candidate: event.candidate.toJSON() }),
547
+ }).catch((err) => {
548
+ console.warn('[WebRTC] ICE trickle POST failed:', err);
549
+ });
550
+ };
551
+ }
552
+
553
+ /** Poll remote ICE candidates from worker after answer is set. */
554
+ private startIceTricklePoll(pc: RTCPeerConnection, iceTrickleUrl: string): void {
555
+ if (isWorkerSdpUrl(iceTrickleUrl)) {
556
+ throw new Error(
557
+ '[WebRTC] ICE poll URL must be /webrtc/ice — got SDP worker_url (GET would 405)',
558
+ );
559
+ }
560
+
561
+ this.stopIceTricklePoll();
562
+ this.seenRemoteIceCandidates.clear();
563
+
564
+ this.iceTricklePollTimer = setInterval(async () => {
565
+ if (
566
+ pc.connectionState === 'connected' ||
567
+ pc.connectionState === 'closed' ||
568
+ pc.connectionState === 'failed'
569
+ ) {
570
+ this.stopIceTricklePoll();
571
+ return;
572
+ }
573
+
574
+ try {
575
+ const response = await fetch(iceTrickleUrl, { method: 'GET' });
576
+ if (!response.ok) {
577
+ return;
578
+ }
579
+ const payload = await readResponseJson<{ candidates?: RTCIceCandidateInit[] }>(
580
+ response,
581
+ 'ICE trickle GET',
582
+ );
583
+ for (const candidate of payload.candidates ?? []) {
584
+ const key = JSON.stringify(candidate);
585
+ if (this.seenRemoteIceCandidates.has(key)) {
586
+ continue;
587
+ }
588
+ this.seenRemoteIceCandidates.add(key);
589
+ try {
590
+ await pc.addIceCandidate(candidate);
591
+ } catch {
592
+ /* ignore duplicate or late candidates */
593
+ }
594
+ }
595
+ } catch (err) {
596
+ console.warn('[WebRTC] ICE trickle poll failed:', err);
597
+ }
598
+ }, ICE_TRICKLE_POLL_MS);
599
+ }
600
+
601
+ private stopIceTricklePoll(): void {
602
+ if (this.iceTricklePollTimer) {
603
+ clearInterval(this.iceTricklePollTimer);
604
+ this.iceTricklePollTimer = null;
605
+ }
606
+ }
607
+
608
+ private isPeerTransportReady(): boolean {
609
+ return (
610
+ this.pc?.connectionState === 'connected' ||
611
+ this.pc?.iceConnectionState === 'connected' ||
612
+ this.pc?.iceConnectionState === 'completed'
613
+ );
614
+ }
615
+
616
+ /** Fire onConnected once peer transport and DataChannel are both ready. */
617
+ private tryNotifyConnected(): void {
618
+ if (this.connectedNotified || !this.connected) {
619
+ return;
620
+ }
621
+ if (!this.isPeerTransportReady() || !this.dcController?.isOpen()) {
622
+ return;
623
+ }
624
+ this.connectedNotified = true;
625
+ this.stopIceTricklePoll();
626
+ if (!this.capabilities.supportsAudioEndedEvent) {
627
+ activateCallAudioPlayback();
628
+ }
629
+ this.onConnected();
630
+ }
631
+
632
+ private flushPendingOutbound(): void {
633
+ if (!this.dcController?.isOpen()) {
634
+ return;
635
+ }
636
+ while (this.pendingOutbound.length > 0) {
637
+ const text = this.pendingOutbound.shift()!;
638
+ this.dcController.send(text);
639
+ try {
640
+ const payload = JSON.parse(text) as { event?: string };
641
+ console.log(`📤 [WebRTC] Flushed queued event: ${payload.event ?? 'unknown'}`);
642
+ } catch {
643
+ console.log('📤 [WebRTC] Flushed queued DataChannel message');
644
+ }
645
+ }
646
+ }
647
+
648
+ private sendDataChannelPayload(payload: Record<string, unknown>): void {
649
+ const text = JSON.stringify(payload);
650
+ const eventName = typeof payload.event === 'string' ? payload.event : 'message';
651
+
652
+ if (this.dcController?.isOpen()) {
653
+ console.log(`📤 [WebRTC] Sending event: ${eventName}`, payload);
654
+ this.dcController.send(text);
655
+ return;
656
+ }
657
+
658
+ if (this.pc && this.pc.connectionState !== 'closed') {
659
+ this.pendingOutbound.push(text);
660
+ console.log(`📤 [WebRTC] Queued event: ${eventName} (DataChannel opening)`);
661
+ return;
662
+ }
663
+
664
+ console.warn(`⚠️ [WebRTC] Cannot send event ${eventName} - DataChannel not open`);
665
+ }
666
+
667
+ sendEvent(event: string, data = {}): void {
668
+ this.sendDataChannelPayload({ event, ...data });
669
+ }
670
+
671
+ /**
672
+ * Observe every inbound DataChannel control event (harness / debugging).
673
+ * Fires before internal routing. Returns false when not connected.
674
+ */
675
+ tapControlEvents(handler: (msg: ControlEvent) => void): boolean {
676
+ this.controlEventTap = handler;
677
+ return true;
678
+ }
679
+
680
+ /** Harness-only: replay an inbound DataChannel frame through normal routing. */
681
+ injectControlEvent(msg: ControlEvent): void {
682
+ this.handleControlEvent(msg);
683
+ }
684
+
685
+ /**
686
+ * RN has no audio onended. Web sends playedStream only when playback finishes.
687
+ * Do NOT send on `mark` — that fires at TTS start and causes the worker to skip audio.
688
+ */
689
+ private schedulePlayedStreamAfterTranscription(): void {
690
+ if (this.capabilities.supportsAudioEndedEvent) {
691
+ return;
692
+ }
693
+ if (this.playedStreamFallbackTimer) {
694
+ clearTimeout(this.playedStreamFallbackTimer);
695
+ }
696
+ this.playedStreamFallbackTimer = setTimeout(() => {
697
+ this.playedStreamFallbackTimer = null;
698
+ this.sendEvent('playedStream');
699
+ console.log(
700
+ '📤 [WebRTC] playedStream sent after final transcription (RN)',
701
+ );
702
+ }, WebRTCClient.PLAYED_STREAM_AFTER_TRANSCRIPTION_MS);
703
+ }
704
+
705
+ private clearPlayedStreamFallback(): void {
706
+ if (this.playedStreamFallbackTimer) {
707
+ clearTimeout(this.playedStreamFallbackTimer);
708
+ this.playedStreamFallbackTimer = null;
709
+ }
710
+ }
711
+
712
+ /** Notify board queue to cancel in-flight write/erase animations. */
713
+ private dispatchBoardAbort(reason: string, toolCallId?: string): void {
714
+ dispatchBoardAbort(reason, toolCallId);
715
+ }
716
+
717
+ private handleControlEvent(msg: ControlEvent): void {
718
+ this.controlEventTap?.(msg);
719
+
720
+ switch (msg.event) {
721
+ case 'clearAudio':
722
+ console.log('🛑 [WebRTC] Interrupt: clearAudio received (leaving stream unpaused)');
723
+ this.clearPlayedStreamFallback();
724
+ this.dispatchBoardAbort('Server interrupted');
725
+ break;
726
+
727
+ case 'transcription':
728
+ console.log('📝 [WebRTC] Transcription:', msg.text);
729
+ // @ts-ignore
730
+ this.onTranscription(msg.text, msg.isFinal);
731
+ // @ts-ignore
732
+ if (!this.capabilities.supportsAudioEndedEvent && msg.isFinal) {
733
+ this.schedulePlayedStreamAfterTranscription();
734
+ }
735
+ break;
736
+
737
+ case 'mark':
738
+ // @ts-ignore
739
+ console.log('🏷️ [WebRTC] Mark:', msg.name);
740
+ break;
741
+
742
+ case 'client_tool_cancel': {
743
+ const cancelData = ((msg as ControlEvent & {data?: Record<string, unknown>}).data || msg) as Record<string, unknown>;
744
+ const cancelledId =
745
+ (cancelData.tool_call_id as string | undefined)
746
+ || ((msg as ControlEvent & {tool_call_id?: string}).tool_call_id);
747
+ console.log('🛑 [WebRTC] client_tool_cancel received:', cancelledId || '(all)');
748
+ this.dispatchBoardAbort('Tool cancelled by server', cancelledId);
749
+ break;
750
+ }
751
+
752
+ case 'client_tool_call': {
753
+ const toolCallData = unwrapToolCallData(msg.tool_call || msg.data || msg);
754
+ const normalized = normalizeToolCallFields(toolCallData);
755
+ const toolCallId = normalized.tool_call_id || (msg as any).tool_call_id || '';
756
+
757
+ if (toolCallId) {
758
+ this.sendToolAck(toolCallId);
759
+ } else {
760
+ console.warn('⚠️ [VaniraAI] client_tool_call received without tool_call_id — ack skipped');
761
+ }
762
+
763
+ const toolArgs = normalized.arguments;
764
+ const clientFields = normalized.client_fields;
765
+ const presetId = clientFields?.preset_id || toolArgs?.preset_id;
766
+
767
+ const wireToolCall = {
768
+ tool_call_id: normalized.tool_call_id,
769
+ name: normalized.name,
770
+ arguments: normalized.arguments,
771
+ execution_mode: normalized.execution_mode,
772
+ client_fields: normalized.client_fields,
773
+ };
774
+
775
+ if (presetId) {
776
+ console.log(`🎨 [VaniraAI] Preset detected: ${presetId}`);
777
+ console.log(`🎨 [VaniraAI] Tool args:`, toolArgs);
778
+ console.log(`🎨 [VaniraAI] Client fields:`, clientFields);
779
+
780
+ const presetPayload: PresetEventPayload = {
781
+ toolCall: wireToolCall,
782
+ client: this,
783
+ };
784
+
785
+ this.onPreset?.(presetPayload);
786
+
787
+ console.log(`🎨 [VaniraAI] Preset "${presetId}" — dispatching vanira:preset event`);
788
+ if (canDispatchBrowserEvent()) {
789
+ globalThis.dispatchEvent(new CustomEvent('vanira:preset', {
790
+ detail: presetPayload,
791
+ }));
792
+ }
793
+
794
+ this.onClientToolCall(wireToolCall);
795
+ break;
796
+ }
797
+
798
+ console.log('🛠️ [VaniraAI] Client Tool Call:', msg);
799
+ this.onClientToolCall(wireToolCall);
800
+ break;
801
+ }
802
+
803
+ default:
804
+ console.log('ℹ️ [WebRTC] Unknown event:', msg.event);
805
+ }
806
+ }
807
+
808
+ disconnect(): void {
809
+ if (this.teardownRequested) {
810
+ return;
811
+ }
812
+ this.teardownRequested = true;
813
+ console.log('🔴 [WebRTC] Disconnecting...');
814
+
815
+ resetBoardSession();
816
+ this.clearPlayedStreamFallback();
817
+ this.stopIceTricklePoll();
818
+ this.controlEventTap = null;
819
+ this.pendingOutbound = [];
820
+ this.connectedNotified = false;
821
+
822
+ if (!this.capabilities.supportsAudioEndedEvent) {
823
+ stopCallAudioSession();
824
+ }
825
+
826
+ if (this.audioPlayer) {
827
+ this.audioPlayer.cleanup();
828
+ this.audioPlayer = null;
829
+ }
830
+
831
+ if (this.dcController) {
832
+ this.dcController.close();
833
+ this.dcController = null;
834
+ }
835
+ this.dataChannel = null;
836
+
837
+ if (this.pc) {
838
+ this.pc.getSenders().forEach(sender => {
839
+ if (sender.track) {
840
+ sender.track.stop();
841
+ }
842
+ });
843
+ this.pc.close();
844
+ this.pc = null;
845
+ }
846
+
847
+ if (this.localStream) {
848
+ this.mediaAdapter.stopStream(this.localStream);
849
+ this.localStream = null;
850
+ }
851
+
852
+ this.connected = false;
853
+ this.onDisconnected();
854
+ }
855
+
856
+ /** Mute or unmute the local microphone without ending the call. */
857
+ setMicrophoneMuted(muted: boolean): boolean {
858
+ if (!this.localStream) {
859
+ return false;
860
+ }
861
+ const tracks =
862
+ typeof this.localStream.getAudioTracks === 'function'
863
+ ? this.localStream.getAudioTracks()
864
+ : [];
865
+ for (const track of tracks) {
866
+ track.enabled = !muted;
867
+ }
868
+ return tracks.length > 0;
869
+ }
870
+
871
+ isMicrophoneMuted(): boolean {
872
+ if (!this.localStream) {
873
+ return false;
874
+ }
875
+ const tracks =
876
+ typeof this.localStream.getAudioTracks === 'function'
877
+ ? this.localStream.getAudioTracks()
878
+ : [];
879
+ if (tracks.length === 0) {
880
+ return false;
881
+ }
882
+ return !tracks[0].enabled;
883
+ }
884
+
885
+ generateCallId(): string {
886
+ return this.callIdPrefix + Date.now() + '_' + Math.random().toString(36).substr(2, 8);
887
+ }
888
+
889
+ sendToolResult(callId: string, result: any): void {
890
+ this.sendEvent('client_tool_result', {
891
+ call_id: callId,
892
+ result: result
893
+ });
894
+ }
895
+
896
+ sendToolAck(toolCallId: string): void {
897
+ this.sendDataChannelPayload({
898
+ event: 'client_tool_ack',
899
+ data: { tool_call_id: toolCallId },
900
+ });
901
+ }
902
+
903
+ sendContextUpdate(context: Record<string, any>): void {
904
+ this.sendEvent('client_context_update', { data: { context } });
905
+ }
906
+
907
+ sendActionTrigger(actionName: string, data: Record<string, any> = {}): void {
908
+ this.sendEvent('client_action_trigger', {
909
+ data: {
910
+ action_name: actionName,
911
+ data
912
+ }
913
+ });
914
+ }
915
+
916
+ triggerActionInterrupt(): void {
917
+ this.sendEvent('action_interrupt');
918
+ console.log('🛑 [VaniraAI] Triggered client-side action interrupt');
919
+ }
920
+
921
+ async uploadMedia(
922
+ file: UploadMediaInput,
923
+ reason: string = 'general',
924
+ message: string = ''
925
+ ): Promise<{ media_id: string; url: string; content_type?: string }> {
926
+ if (!this.serverUrl) {
927
+ throw new Error('Upload failed: serverUrl is not set. Connect the WebRTCClient first.');
928
+ }
929
+ if (!this.callId) {
930
+ throw new Error('Upload failed: callId is missing.');
931
+ }
932
+
933
+ let uploadBase = '';
934
+ try {
935
+ uploadBase = new URL(this.serverUrl).origin;
936
+ } catch (e) {
937
+ uploadBase = this.serverUrl.replace(/\/webrtc.*$/, '').replace(/\/$/, '');
938
+ }
939
+ const uploadUrl = `${uploadBase}/media/upload`;
940
+
941
+ const formData = new FormData();
942
+ const uploadPayload = isRNUploadFileDescriptor(file)
943
+ ? {
944
+ ...file,
945
+ type: normalizeUploadMimeForUpload(file),
946
+ }
947
+ : file;
948
+ formData.append('file', uploadPayload as Blob);
949
+ formData.append('call_id', this.callId);
950
+ formData.append('reason', reason);
951
+
952
+ const fileLabel = isRNUploadFileDescriptor(file)
953
+ ? `${file.name} (${file.type}, uri=${file.uri.slice(0, 48)}…)`
954
+ : `blob type=${uploadFileMimeType(file)}`;
955
+ console.log(
956
+ `[VaniraAI] Uploading media to ${uploadUrl} (Call: ${this.callId}, Reason: ${reason}, File: ${fileLabel})...`
957
+ );
958
+ const response = await fetch(uploadUrl, {
959
+ method: 'POST',
960
+ body: formData,
961
+ });
962
+
963
+ if (!response.ok) {
964
+ let errorMessage = `HTTP ${response.status}`;
965
+ try {
966
+ const errData = await response.json();
967
+ errorMessage = errData.error || errorMessage;
968
+ } catch (_) { }
969
+ throw new Error(`Upload failed: ${errorMessage}`);
970
+ }
971
+
972
+ const data = await response.json();
973
+ const mediaId = data.media_id;
974
+ const mediaUrl = data.url;
975
+ const contentType = data.content_type || uploadFileMimeType(file);
976
+
977
+ if (!mediaId || !mediaUrl) {
978
+ throw new Error('Upload failed: server response missing media_id or url');
979
+ }
980
+
981
+ console.log(`[VaniraAI] Media uploaded successfully. ID: ${mediaId}, URL: ${mediaUrl}`);
982
+
983
+ const notification = {
984
+ event: 'client_media_update',
985
+ data: {
986
+ media_id: mediaId,
987
+ media_url: mediaUrl,
988
+ content_type: contentType,
989
+ reason,
990
+ message,
991
+ },
992
+ };
993
+ this.sendDataChannelPayload(notification);
994
+ if (this.dcController?.isOpen()) {
995
+ console.log('[VaniraAI] Sent client_media_update notification via DataChannel:', notification);
996
+ }
997
+
998
+ return { media_id: mediaId, url: mediaUrl, content_type: contentType };
999
+ }
1000
+
1001
+ /**
1002
+ * @deprecated ICE servers come from POST /calls/create only.
1003
+ */
1004
+ public static async fetchIceServers(
1005
+ _apiKey: string,
1006
+ _backendUrl: string = 'https://api.vanira.io',
1007
+ ): Promise<RTCIceServer[]> {
1008
+ throw new Error(
1009
+ '[VaniraSDK] fetchIceServers is removed — use ice_servers from POST /calls/create',
1010
+ );
1011
+ }
1012
+ }