@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
package/README.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# @vanira/sdk-react-native
|
|
2
|
+
|
|
3
|
+
React Native SDK for Vanira voice AI + interactive preset protocol (calendar, forms, upload, camera, navigation, live vision, etc.).
|
|
4
|
+
|
|
5
|
+
Separate from the web package [`@vanira/sdk`](https://www.npmjs.com/package/@vanira/sdk).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @vanira/sdk-react-native \
|
|
13
|
+
react-native-webrtc \
|
|
14
|
+
react-native-permissions \
|
|
15
|
+
react-native-incall-manager \
|
|
16
|
+
react-native-image-picker \
|
|
17
|
+
@react-native-documents/picker \
|
|
18
|
+
react-native-view-shot \
|
|
19
|
+
react-native-vision-camera
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**iOS** — add permission pods, then rebuild:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# ios/Podfile
|
|
26
|
+
require_relative '../node_modules/react-native-permissions/scripts/setup'
|
|
27
|
+
|
|
28
|
+
setup_permissions([
|
|
29
|
+
'Camera',
|
|
30
|
+
'Microphone',
|
|
31
|
+
'PhotoLibrary',
|
|
32
|
+
])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd ios && pod install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Android** — add to `AndroidManifest.xml`:
|
|
40
|
+
|
|
41
|
+
```xml
|
|
42
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
43
|
+
<uses-permission android:name="android.permission.CAMERA" />
|
|
44
|
+
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**App entry** — call `registerGlobals()` once before any SDK import:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// index.js
|
|
51
|
+
import {AppRegistry} from 'react-native';
|
|
52
|
+
import {registerGlobals} from 'react-native-webrtc';
|
|
53
|
+
|
|
54
|
+
registerGlobals();
|
|
55
|
+
|
|
56
|
+
import App from './App';
|
|
57
|
+
import {name as appName} from './app.json';
|
|
58
|
+
|
|
59
|
+
AppRegistry.registerComponent(appName, () => App);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
### 1. Wrap your app with `PresetHostProvider`
|
|
67
|
+
|
|
68
|
+
The SDK renders preset modals (upload, calendar, camera, etc.) at the root.
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// App.tsx
|
|
72
|
+
import {PresetHostProvider} from '@vanira/sdk-react-native';
|
|
73
|
+
|
|
74
|
+
export default function App() {
|
|
75
|
+
return (
|
|
76
|
+
<PresetHostProvider>
|
|
77
|
+
{/* your navigation / screens */}
|
|
78
|
+
</PresetHostProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 2. Start a voice session with `createReactNativeAI`
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import {
|
|
87
|
+
createReactNativeAI,
|
|
88
|
+
usePresetHost,
|
|
89
|
+
type ClientToolCall,
|
|
90
|
+
type VaniraAI,
|
|
91
|
+
} from '@vanira/sdk-react-native';
|
|
92
|
+
|
|
93
|
+
function VoiceScreen() {
|
|
94
|
+
const {setAiClient, setActiveToolCall} = usePresetHost();
|
|
95
|
+
const aiRef = useRef<VaniraAI | null>(null);
|
|
96
|
+
|
|
97
|
+
const startCall = async () => {
|
|
98
|
+
const ai = createReactNativeAI({
|
|
99
|
+
agentId: 'YOUR_AGENT_ID',
|
|
100
|
+
apiKey: 'pk_live_...', // or sk_live_...
|
|
101
|
+
backendUrl: 'https://api.vanira.io',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Route preset + tool calls to the SDK renderer
|
|
105
|
+
ai.on('preset', ({toolCall}) => setActiveToolCall(toolCall));
|
|
106
|
+
ai.on('tool_call', (toolCall: ClientToolCall) => setActiveToolCall(toolCall));
|
|
107
|
+
|
|
108
|
+
ai.on('connected', () => console.log('connected'));
|
|
109
|
+
ai.on('disconnected', () => {
|
|
110
|
+
aiRef.current = null;
|
|
111
|
+
setAiClient(null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
aiRef.current = ai;
|
|
115
|
+
setAiClient(ai); // required — presets use ai.uploadMedia(), sendToolResult(), etc.
|
|
116
|
+
await ai.start();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const stopCall = () => {
|
|
120
|
+
aiRef.current?.stop();
|
|
121
|
+
aiRef.current = null;
|
|
122
|
+
setAiClient(null);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// render start/stop UI…
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 3. Handle blocking tool results (optional)
|
|
130
|
+
|
|
131
|
+
For tools with `execution_mode: 'blocking'`, send a result when your UI completes:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
ai.sendToolResult(toolCall.tool_call_id, {success: true, data: {...}});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Preset modals in the SDK call this automatically when the user submits or cancels.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Main exports
|
|
142
|
+
|
|
143
|
+
| Export | Purpose |
|
|
144
|
+
|--------|---------|
|
|
145
|
+
| `createReactNativeAI()` | **Recommended** — voice client with RN adapters wired in |
|
|
146
|
+
| `createReactNativeClient()` | Lower-level WebRTC client (same adapters) |
|
|
147
|
+
| `PresetHostProvider` | App root — mounts preset UI overlay |
|
|
148
|
+
| `usePresetHost()` | `{setAiClient, setActiveToolCall, clearPreset}` |
|
|
149
|
+
| `VaniraAI` / `WebRTCClient` | Core classes (usually via factories above) |
|
|
150
|
+
| `extractPresetId()` | Parse preset id from a tool call payload |
|
|
151
|
+
| `hasContinueSession()` / `loadContinueSession()` | Resume last call (needs storage adapter) |
|
|
152
|
+
| `createSyncStorageAdapter()` | Plug AsyncStorage (or similar) into the runtime |
|
|
153
|
+
|
|
154
|
+
Legacy aliases `createVaniraAI` / `createVaniraClient` still work but are deprecated.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Continue / resume calls
|
|
159
|
+
|
|
160
|
+
Pass a storage backend on the runtime to persist `prospect_id` + `call_id` between sessions:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import {
|
|
164
|
+
createReactNativeAI,
|
|
165
|
+
createSyncStorageAdapter,
|
|
166
|
+
reactNativeRuntime,
|
|
167
|
+
} from '@vanira/sdk-react-native';
|
|
168
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
169
|
+
|
|
170
|
+
const runtime = {
|
|
171
|
+
...reactNativeRuntime,
|
|
172
|
+
storage: createSyncStorageAdapter(AsyncStorage),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const ai = createReactNativeAI({
|
|
176
|
+
agentId: 'YOUR_AGENT_ID',
|
|
177
|
+
apiKey: 'pk_live_...',
|
|
178
|
+
backendUrl: 'https://api.vanira.io',
|
|
179
|
+
sessionBehavior: 'continue', // or 'new'
|
|
180
|
+
runtime,
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Metro (npm install)
|
|
187
|
+
|
|
188
|
+
When installed from npm (not a local `file:` path), Metro must transpile the SDK’s TypeScript source:
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
// metro.config.js
|
|
192
|
+
const path = require('path');
|
|
193
|
+
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
|
194
|
+
|
|
195
|
+
module.exports = mergeConfig(getDefaultConfig(__dirname), {
|
|
196
|
+
resolver: {unstable_enablePackageExports: true},
|
|
197
|
+
watchFolders: [
|
|
198
|
+
path.resolve(__dirname, 'node_modules/@vanira/sdk-react-native'),
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
For monorepo local dev with `"file:../vanira-sdk-rn"`, point Metro at the sibling folder (see `MyApp/metro.config.js` in this repo).
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Presets included
|
|
208
|
+
|
|
209
|
+
| Preset | Description |
|
|
210
|
+
|--------|-------------|
|
|
211
|
+
| `vanira_upload` | File picker — JPEG, PNG, PDF, TXT, CSV |
|
|
212
|
+
| `vanira_calendar` | Date / booking picker |
|
|
213
|
+
| `vanira_form` | Dynamic form modal |
|
|
214
|
+
| `vanira_camera` | Camera capture + upload |
|
|
215
|
+
| `vanira_navigate` | In-app or external navigation |
|
|
216
|
+
| `vanira_live_vision` | Live camera frames (requires `react-native-vision-camera`) |
|
|
217
|
+
| Chalkboard | Type / erase text overlays |
|
|
218
|
+
|
|
219
|
+
The app only needs to call `setActiveToolCall()` — the SDK renders the UI and sends results back to the agent.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Platform notes
|
|
224
|
+
|
|
225
|
+
- Import from **`@vanira/sdk-react-native`** only — do not use `@vanira/sdk` (web) in RN apps.
|
|
226
|
+
- `registerGlobals()` from `react-native-webrtc` is **required** at app entry.
|
|
227
|
+
- Live vision uses Vision Camera (not `RTCView` + ViewShot — black frames on iOS).
|
|
228
|
+
- Upload uses gallery + document picker; iOS needs Photo Library permission in Podfile + Info.plist.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Relationship to web SDK
|
|
233
|
+
|
|
234
|
+
| Package | Platform |
|
|
235
|
+
|---------|----------|
|
|
236
|
+
| `@vanira/sdk` | Browser / web widget |
|
|
237
|
+
| `@vanira/sdk-react-native` | React Native |
|
|
238
|
+
|
|
239
|
+
Same voice protocol and preset IDs; different adapters and UI layer.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vanira/sdk-react-native",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Vanira Voice SDK for React Native — WebRTC, presets protocol, RN adapters",
|
|
5
|
+
"author": "Vanira <developers@vanira.io>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./src/index.ts",
|
|
13
|
+
"types": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "vitest run --config vitest.config.ts",
|
|
21
|
+
"test:watch": "vitest --config vitest.config.ts",
|
|
22
|
+
"prepublishOnly": "npm test"
|
|
23
|
+
},
|
|
24
|
+
"react-native": "./src/index.ts",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/vanira-ai/sdk"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"react-native",
|
|
31
|
+
"voice-ai",
|
|
32
|
+
"webrtc",
|
|
33
|
+
"vanira",
|
|
34
|
+
"sdk"
|
|
35
|
+
],
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=18",
|
|
38
|
+
"react-native": ">=0.74",
|
|
39
|
+
"react-native-webrtc": ">=124",
|
|
40
|
+
"react-native-image-picker": ">=8",
|
|
41
|
+
"@react-native-documents/picker": ">=10",
|
|
42
|
+
"react-native-view-shot": ">=4",
|
|
43
|
+
"react-native-incall-manager": ">=4",
|
|
44
|
+
"react-native-permissions": ">=5",
|
|
45
|
+
"react-native-vision-camera": ">=4.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@vitest/coverage-v8": "^4.1.6",
|
|
49
|
+
"typescript": "^5.0.0",
|
|
50
|
+
"vite": "^5.0.0",
|
|
51
|
+
"vitest": "^4.1.6"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTCClient adapter wiring integration tests (Task 0.2)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
|
|
7
|
+
const TEST_ICE_SERVERS = [{ urls: 'stun:stun.test.example:3478' }] as RTCIceServer[];
|
|
8
|
+
const TEST_WORKER_URL = 'https://worker.example.com/webrtc?token=abc';
|
|
9
|
+
|
|
10
|
+
function mockFetchResponse(body: unknown): Response {
|
|
11
|
+
return {
|
|
12
|
+
ok: true,
|
|
13
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
14
|
+
} as Response;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeMockRTCPeerConnection() {
|
|
18
|
+
return {
|
|
19
|
+
addTrack: vi.fn(),
|
|
20
|
+
createDataChannel: vi.fn(() => makeMockRTCDataChannel()),
|
|
21
|
+
createOffer: vi.fn(() => Promise.resolve({ type: 'offer', sdp: 'mock-sdp' })),
|
|
22
|
+
setLocalDescription: vi.fn(() => Promise.resolve()),
|
|
23
|
+
setRemoteDescription: vi.fn(() => Promise.resolve()),
|
|
24
|
+
getSenders: vi.fn(() => []),
|
|
25
|
+
close: vi.fn(),
|
|
26
|
+
iceGatheringState: 'complete',
|
|
27
|
+
connectionState: 'new',
|
|
28
|
+
iceConnectionState: 'new',
|
|
29
|
+
localDescription: { type: 'offer', sdp: 'mock-sdp' },
|
|
30
|
+
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
|
31
|
+
onconnectionstatechange: null as (() => void) | null,
|
|
32
|
+
oniceconnectionstatechange: null as (() => void) | null,
|
|
33
|
+
onicecandidate: null as ((event: RTCPeerConnectionIceEvent) => void) | null,
|
|
34
|
+
addEventListener: vi.fn(),
|
|
35
|
+
removeEventListener: vi.fn(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeMockRTCDataChannel() {
|
|
40
|
+
return {
|
|
41
|
+
send: vi.fn(),
|
|
42
|
+
close: vi.fn(),
|
|
43
|
+
readyState: 'open' as RTCDataChannelState,
|
|
44
|
+
onopen: null as (() => void) | null,
|
|
45
|
+
onmessage: null as ((e: MessageEvent) => void) | null,
|
|
46
|
+
onerror: null as ((e: Event) => void) | null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('WebRTCClient constructor storage', () => {
|
|
51
|
+
it('reads prospect and call id from storage when sessionBehavior is continue', async () => {
|
|
52
|
+
const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
|
|
53
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
54
|
+
|
|
55
|
+
const storage = new MemoryStorageAdapter();
|
|
56
|
+
storage.setItem('vanira_prospect_id', 'prospect_stored');
|
|
57
|
+
storage.setItem('vanira_latest_call_id', 'call_stored');
|
|
58
|
+
|
|
59
|
+
const client = new WebRTCClient({
|
|
60
|
+
agentId: 'agent_1',
|
|
61
|
+
serverUrl: 'https://worker.example.com',
|
|
62
|
+
sessionBehavior: 'continue',
|
|
63
|
+
storageAdapter: storage,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(client.prospectId).toBe('prospect_stored');
|
|
67
|
+
expect(client.callId).toBe('call_stored');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not read call id from storage when sessionBehavior is not continue', async () => {
|
|
71
|
+
const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
|
|
72
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
73
|
+
|
|
74
|
+
const storage = new MemoryStorageAdapter();
|
|
75
|
+
storage.setItem('vanira_latest_call_id', 'call_stored');
|
|
76
|
+
|
|
77
|
+
const client = new WebRTCClient({
|
|
78
|
+
agentId: 'agent_1',
|
|
79
|
+
serverUrl: 'https://worker.example.com',
|
|
80
|
+
storageAdapter: storage,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(client.callId).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('WebRTCClient generateCallId', () => {
|
|
88
|
+
it('uses web_ prefix for bare web client', async () => {
|
|
89
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
90
|
+
const client = new WebRTCClient({
|
|
91
|
+
agentId: 'agent_1',
|
|
92
|
+
serverUrl: 'https://worker.example.com',
|
|
93
|
+
});
|
|
94
|
+
const id = client.generateCallId();
|
|
95
|
+
expect(id.startsWith('web_')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('uses rn_ prefix for react-native runtime', async () => {
|
|
99
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
100
|
+
const { reactNativeRuntime } = await import('../runtime/reactNativeRuntime');
|
|
101
|
+
|
|
102
|
+
const client = new WebRTCClient({
|
|
103
|
+
agentId: 'agent_1',
|
|
104
|
+
serverUrl: 'https://worker.example.com',
|
|
105
|
+
runtime: reactNativeRuntime,
|
|
106
|
+
});
|
|
107
|
+
const id = client.generateCallId();
|
|
108
|
+
expect(id.startsWith('rn_')).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('WebRTCClient createCall body', () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
115
|
+
value: { product: 'Gecko', mediaDevices: { getUserMedia: vi.fn() } },
|
|
116
|
+
configurable: true,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
vi.restoreAllMocks();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('includes runtime browser for bare web client', async () => {
|
|
125
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
126
|
+
mockFetchResponse({
|
|
127
|
+
call_id: 'c1',
|
|
128
|
+
worker_url: TEST_WORKER_URL,
|
|
129
|
+
ice_servers: [{ urls: 'stun:stun.test.example:3478' }],
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
134
|
+
const client = new WebRTCClient({
|
|
135
|
+
agentId: 'agent_1',
|
|
136
|
+
apiKey: 'sk_test',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await client.createCall();
|
|
141
|
+
} catch {
|
|
142
|
+
// connect may fail in test env
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
|
146
|
+
expect(body.type).toBe('web');
|
|
147
|
+
expect(body.runtime).toBe('browser');
|
|
148
|
+
expect(body.mode).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('includes mode continue only when sessionBehavior is continue', async () => {
|
|
152
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
153
|
+
mockFetchResponse({
|
|
154
|
+
call_id: 'c1',
|
|
155
|
+
worker_url: TEST_WORKER_URL,
|
|
156
|
+
ice_servers: [{ urls: 'stun:stun.test.example:3478' }],
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
161
|
+
const client = new WebRTCClient({
|
|
162
|
+
agentId: 'agent_1',
|
|
163
|
+
apiKey: 'sk_test',
|
|
164
|
+
sessionBehavior: 'continue',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await client.createCall();
|
|
169
|
+
} catch {
|
|
170
|
+
// connect may fail
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
|
174
|
+
expect(body.mode).toBe('continue');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('persists ids via storage after createCall', async () => {
|
|
178
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
179
|
+
mockFetchResponse({
|
|
180
|
+
call_id: 'call_new',
|
|
181
|
+
prospect_id: 'prospect_new',
|
|
182
|
+
worker_url: TEST_WORKER_URL,
|
|
183
|
+
ice_servers: [{ urls: 'stun:stun.test.example:3478' }],
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
|
|
188
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
189
|
+
|
|
190
|
+
const storage = new MemoryStorageAdapter();
|
|
191
|
+
const client = new WebRTCClient({
|
|
192
|
+
agentId: 'agent_1',
|
|
193
|
+
apiKey: 'sk_test',
|
|
194
|
+
storageAdapter: storage,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await client.createCall();
|
|
199
|
+
} catch {
|
|
200
|
+
// connect may fail
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
expect(storage.getItem('vanira_prospect_id')).toBe('prospect_new');
|
|
204
|
+
expect(storage.getItem('vanira_latest_call_id')).toBe('call_new');
|
|
205
|
+
expect(fetchSpy).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('WebRTCClient adapter wiring in connect', () => {
|
|
210
|
+
let mockPc: ReturnType<typeof makeMockRTCPeerConnection>;
|
|
211
|
+
let mockChannel: ReturnType<typeof makeMockRTCDataChannel>;
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
mockPc = makeMockRTCPeerConnection();
|
|
215
|
+
mockChannel = makeMockRTCDataChannel();
|
|
216
|
+
mockPc.createDataChannel.mockReturnValue(mockChannel);
|
|
217
|
+
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
219
|
+
function MockRTC(this: any) {
|
|
220
|
+
Object.assign(this, mockPc);
|
|
221
|
+
}
|
|
222
|
+
(globalThis as unknown as Record<string, unknown>).RTCPeerConnection = MockRTC;
|
|
223
|
+
|
|
224
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
225
|
+
value: {
|
|
226
|
+
product: 'Gecko',
|
|
227
|
+
userAgent: 'Mozilla/5.0',
|
|
228
|
+
platform: 'MacIntel',
|
|
229
|
+
maxTouchPoints: 0,
|
|
230
|
+
mediaDevices: {
|
|
231
|
+
getUserMedia: vi.fn(() => Promise.resolve({
|
|
232
|
+
getTracks: () => [{ kind: 'audio', stop: vi.fn() }],
|
|
233
|
+
})),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
configurable: true,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
|
240
|
+
const url = String(input);
|
|
241
|
+
if (url.includes('/webrtc/ice')) {
|
|
242
|
+
return Promise.resolve({
|
|
243
|
+
ok: true,
|
|
244
|
+
text: () => Promise.resolve(JSON.stringify({ candidates: [] })),
|
|
245
|
+
} as Response);
|
|
246
|
+
}
|
|
247
|
+
return Promise.resolve({
|
|
248
|
+
ok: true,
|
|
249
|
+
text: () =>
|
|
250
|
+
Promise.resolve(
|
|
251
|
+
JSON.stringify({ answer: { type: 'answer', sdp: 'answer-sdp' } }),
|
|
252
|
+
),
|
|
253
|
+
} as Response);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
afterEach(() => {
|
|
258
|
+
vi.restoreAllMocks();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('calls peerAdapter.create with configured iceServers', async () => {
|
|
262
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
263
|
+
const { browserRuntime } = await import('../runtime/browserRuntime');
|
|
264
|
+
|
|
265
|
+
const createSpy = vi.spyOn(browserRuntime.peer, 'create');
|
|
266
|
+
|
|
267
|
+
const customIce = [{ urls: 'stun:custom.example.com:3478' }];
|
|
268
|
+
const client = new WebRTCClient({
|
|
269
|
+
agentId: 'agent_1',
|
|
270
|
+
serverUrl: TEST_WORKER_URL,
|
|
271
|
+
callId: 'call_1',
|
|
272
|
+
prospectId: 'p_1',
|
|
273
|
+
runtime: browserRuntime,
|
|
274
|
+
iceServers: customIce as RTCIceServer[],
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await client.connect();
|
|
278
|
+
|
|
279
|
+
expect(createSpy).toHaveBeenCalledWith({
|
|
280
|
+
iceServers: customIce,
|
|
281
|
+
iceTransportPolicy: 'all',
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('calls mediaAdapter.getUserAudio with audio constraints', async () => {
|
|
286
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
287
|
+
const { browserRuntime } = await import('../runtime/browserRuntime');
|
|
288
|
+
|
|
289
|
+
const getUserSpy = vi.spyOn(browserRuntime.media, 'getUserAudio');
|
|
290
|
+
|
|
291
|
+
const client = new WebRTCClient({
|
|
292
|
+
agentId: 'agent_1',
|
|
293
|
+
serverUrl: TEST_WORKER_URL,
|
|
294
|
+
callId: 'call_1',
|
|
295
|
+
runtime: browserRuntime,
|
|
296
|
+
iceServers: TEST_ICE_SERVERS,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await client.connect();
|
|
300
|
+
|
|
301
|
+
expect(getUserSpy).toHaveBeenCalledWith({
|
|
302
|
+
echoCancellation: true,
|
|
303
|
+
noiseSuppression: true,
|
|
304
|
+
autoGainControl: true,
|
|
305
|
+
sampleRate: { ideal: 16000 },
|
|
306
|
+
channelCount: 1,
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('binds dataChannelAdapter and routes string messages to handleControlEvent', async () => {
|
|
311
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
312
|
+
const { browserRuntime } = await import('../runtime/browserRuntime');
|
|
313
|
+
|
|
314
|
+
const onTranscription = vi.fn();
|
|
315
|
+
const bindSpy = vi.spyOn(browserRuntime.dataChannel, 'bind');
|
|
316
|
+
|
|
317
|
+
const client = new WebRTCClient({
|
|
318
|
+
agentId: 'agent_1',
|
|
319
|
+
serverUrl: 'https://worker.example.com/webrtc?token=abc',
|
|
320
|
+
callId: 'call_1',
|
|
321
|
+
runtime: browserRuntime,
|
|
322
|
+
iceServers: TEST_ICE_SERVERS,
|
|
323
|
+
onTranscription,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await client.connect();
|
|
327
|
+
|
|
328
|
+
expect(bindSpy).toHaveBeenCalled();
|
|
329
|
+
const handlers = bindSpy.mock.calls[0][1];
|
|
330
|
+
handlers.onMessage({
|
|
331
|
+
text: JSON.stringify({ event: 'transcription', text: 'hello', isFinal: true }),
|
|
332
|
+
});
|
|
333
|
+
expect(onTranscription).toHaveBeenCalledWith('hello', true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('sendEvent uses dcController when channel is open', async () => {
|
|
337
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
338
|
+
const { browserRuntime } = await import('../runtime/browserRuntime');
|
|
339
|
+
|
|
340
|
+
const bindSpy = vi.spyOn(browserRuntime.dataChannel, 'bind');
|
|
341
|
+
|
|
342
|
+
const client = new WebRTCClient({
|
|
343
|
+
agentId: 'agent_1',
|
|
344
|
+
serverUrl: TEST_WORKER_URL,
|
|
345
|
+
callId: 'call_1',
|
|
346
|
+
runtime: browserRuntime,
|
|
347
|
+
iceServers: TEST_ICE_SERVERS,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await client.connect();
|
|
351
|
+
|
|
352
|
+
const handlers = bindSpy.mock.calls[0][1];
|
|
353
|
+
handlers.onOpen();
|
|
354
|
+
|
|
355
|
+
client.sendEvent('test_event', { foo: 'bar' });
|
|
356
|
+
expect(mockChannel.send).toHaveBeenCalledWith(
|
|
357
|
+
JSON.stringify({ event: 'test_event', foo: 'bar' })
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('WebRTCClient handleControlEvent ack ordering', () => {
|
|
363
|
+
it('sendToolAck fires before onClientToolCall for preset tools', async () => {
|
|
364
|
+
const { WebRTCClient } = await import('../core/WebRTCClient');
|
|
365
|
+
const { browserRuntime } = await import('../runtime/browserRuntime');
|
|
366
|
+
|
|
367
|
+
const order: string[] = [];
|
|
368
|
+
const onClientToolCall = vi.fn(() => order.push('callback'));
|
|
369
|
+
|
|
370
|
+
const client = new WebRTCClient({
|
|
371
|
+
agentId: 'agent_1',
|
|
372
|
+
serverUrl: 'https://worker.example.com',
|
|
373
|
+
callId: 'call_1',
|
|
374
|
+
runtime: browserRuntime,
|
|
375
|
+
onPreset: () => order.push('onPreset'),
|
|
376
|
+
onClientToolCall,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const sendToolAckSpy = vi.spyOn(client, 'sendToolAck').mockImplementation(() => {
|
|
380
|
+
order.push('ack');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
384
|
+
(client as any).handleControlEvent({
|
|
385
|
+
event: 'client_tool_call',
|
|
386
|
+
tool_call: {
|
|
387
|
+
tool_call_id: 'tc_1',
|
|
388
|
+
arguments: { preset_id: 'demo' },
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(sendToolAckSpy).toHaveBeenCalledWith('tc_1');
|
|
393
|
+
expect(onClientToolCall).toHaveBeenCalled();
|
|
394
|
+
expect(order).toEqual(['ack', 'onPreset', 'callback']);
|
|
395
|
+
});
|
|
396
|
+
});
|