@elevenlabs/react-native 0.1.0

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/ElevenLabsProvider.d.ts +22 -0
  4. package/dist/ElevenLabsProvider.test.d.ts +1 -0
  5. package/dist/components/LiveKitRoomWrapper.d.ts +20 -0
  6. package/dist/components/MessageHandler.d.ts +12 -0
  7. package/dist/hooks/useConversationCallbacks.d.ts +5 -0
  8. package/dist/hooks/useConversationSession.d.ts +28 -0
  9. package/dist/hooks/useLiveKitRoom.d.ts +12 -0
  10. package/dist/hooks/useMessageSending.d.ts +7 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/lib.js +2 -0
  13. package/dist/lib.js.map +1 -0
  14. package/dist/lib.modern.js +2 -0
  15. package/dist/lib.modern.js.map +1 -0
  16. package/dist/lib.module.js +2 -0
  17. package/dist/lib.module.js.map +1 -0
  18. package/dist/lib.umd.js +2 -0
  19. package/dist/lib.umd.js.map +1 -0
  20. package/dist/types.d.ts +109 -0
  21. package/dist/utils/constants.d.ts +1 -0
  22. package/dist/utils/overrides.d.ts +2 -0
  23. package/dist/utils/tokenUtils.d.ts +2 -0
  24. package/dist/version.d.ts +1 -0
  25. package/eslint.config.cjs +1 -0
  26. package/jest.config.cjs +263 -0
  27. package/jest.rn-mock.js +15 -0
  28. package/package.json +64 -0
  29. package/src/ElevenLabsProvider.test.tsx +187 -0
  30. package/src/ElevenLabsProvider.tsx +259 -0
  31. package/src/components/LiveKitRoomWrapper.tsx +63 -0
  32. package/src/components/MessageHandler.tsx +127 -0
  33. package/src/hooks/useConversationCallbacks.ts +15 -0
  34. package/src/hooks/useConversationSession.ts +88 -0
  35. package/src/hooks/useLiveKitRoom.ts +62 -0
  36. package/src/hooks/useMessageSending.ts +33 -0
  37. package/src/index.ts +9 -0
  38. package/src/types.ts +150 -0
  39. package/src/utils/constants.ts +1 -0
  40. package/src/utils/overrides.ts +40 -0
  41. package/src/utils/tokenUtils.ts +31 -0
  42. package/src/version.ts +2 -0
  43. package/tsconfig.json +18 -0
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@elevenlabs/react-native",
3
+ "version": "0.1.0",
4
+ "description": "ElevenLabs React Native SDK for Conversational AI",
5
+ "main": "./dist/lib.js",
6
+ "module": "./dist/lib.module.js",
7
+ "source": "src/index.ts",
8
+ "unpkg": "./dist/lib.umd.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/lib.modern.js",
14
+ "require": "./dist/lib.js"
15
+ }
16
+ },
17
+ "keywords": [
18
+ "elevenlabs",
19
+ "react-native",
20
+ "conversational-ai",
21
+ "webrtc",
22
+ "voice",
23
+ "ai"
24
+ ],
25
+ "author": "ElevenLabs",
26
+ "license": "MIT",
27
+ "peerDependencies": {
28
+ "@livekit/react-native": "^2.7.0",
29
+ "@livekit/react-native-webrtc": "^125.0.0",
30
+ "livekit-client": "^2.11.0",
31
+ "react": ">=17.0.0",
32
+ "react-native": ">=0.70.0"
33
+ },
34
+ "devDependencies": {
35
+ "@testing-library/jest-native": "^5.4.3",
36
+ "@testing-library/react-native": "^13.2.0",
37
+ "@types/jest": "^29.5.12",
38
+ "@types/react": "^19.0.0",
39
+ "@types/react-native": "^0.73.0",
40
+ "eslint": "^9.8.0",
41
+ "jest": "^29.7.0",
42
+ "microbundle": "^0.15.1",
43
+ "react-test-renderer": "19.0.0",
44
+ "ts-jest": "^29.2.5",
45
+ "typescript": "^5.5.4"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/elevenlabs/packages.git",
50
+ "directory": "packages/react-native"
51
+ },
52
+ "scripts": {
53
+ "generate-version": "printf \"// This file is auto-generated during build\\nexport const PACKAGE_VERSION = \\\"%s\\\";\\n\" \"$npm_package_version\" > src/version.ts",
54
+ "prebuild": "npm run generate-version",
55
+ "build": "BROWSERSLIST_ENV=modern microbundle --jsx React.createElement --jsxFragment React.Fragment --jsxImportSource react src/index.ts",
56
+ "clean": "rm -rf ./dist",
57
+ "dev": "npm run clean && npm run generate-version && BROWSERSLIST_ENV=modern microbundle --jsx React.createElement --jsxFragment React.Fragment --jsxImportSource react src/index.ts -w --no-compress",
58
+ "lint": "npm run lint:ts && npm run lint:es && npm run lint:prettier",
59
+ "lint:ts": "tsc --noEmit --skipLibCheck",
60
+ "lint:es": "npx eslint .",
61
+ "lint:prettier": "prettier 'src/**/*.ts' --check",
62
+ "test": "jest"
63
+ }
64
+ }
@@ -0,0 +1,187 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react-native';
3
+ import { ElevenLabsProvider, useConversation } from './ElevenLabsProvider';
4
+ import type { ReactNode } from 'react';
5
+
6
+ // Suppress react-test-renderer deprecation warnings during tests
7
+ const originalWarn = console.warn;
8
+ const originalError = console.error;
9
+
10
+ beforeAll(() => {
11
+ console.warn = (...args) => {
12
+ if (args[0]?.includes?.('react-test-renderer is deprecated')) return;
13
+ originalWarn(...args);
14
+ };
15
+ console.error = (...args) => {
16
+ if (args[0]?.includes?.('react-test-renderer is deprecated')) return;
17
+ originalError(...args);
18
+ };
19
+ });
20
+
21
+ afterAll(() => {
22
+ console.warn = originalWarn;
23
+ console.error = originalError;
24
+ });
25
+
26
+ // Simple test component to avoid React Native import issues
27
+ const TestText = ({ children }: { children: ReactNode }) => (
28
+ <div>{children}</div>
29
+ );
30
+
31
+ // Mock LiveKit
32
+ jest.mock('@livekit/react-native', () => ({
33
+ registerGlobals: jest.fn(),
34
+ }));
35
+
36
+ // Mock hooks with inline functions to avoid hoisting issues
37
+ jest.mock('./hooks/useConversationSession', () => ({
38
+ useConversationSession: () => ({
39
+ startSession: jest.fn(),
40
+ endSession: jest.fn(),
41
+ overrides: {},
42
+ customLlmExtraBody: undefined,
43
+ defaultServerUrl: 'https://api.elevenlabs.io/v1/convai',
44
+ }),
45
+ }));
46
+
47
+ jest.mock('./hooks/useConversationCallbacks', () => ({
48
+ useConversationCallbacks: () => ({
49
+ callbacksRef: { current: {} },
50
+ setCallbacks: jest.fn(),
51
+ }),
52
+ }));
53
+
54
+ jest.mock('./hooks/useLiveKitRoom', () => ({
55
+ useLiveKitRoom: () => ({
56
+ room: null,
57
+ localParticipant: null,
58
+ roomConnected: false,
59
+ handleParticipantReady: jest.fn(),
60
+ handleConnected: jest.fn(),
61
+ handleDisconnected: jest.fn(),
62
+ handleError: jest.fn(),
63
+ }),
64
+ }));
65
+
66
+ jest.mock('./hooks/useMessageSending', () => ({
67
+ useMessageSending: () => ({
68
+ sendMessage: jest.fn(),
69
+ sendFeedback: jest.fn(),
70
+ sendContextualUpdate: jest.fn(),
71
+ sendUserMessage: jest.fn(),
72
+ sendUserActivity: jest.fn(),
73
+ }),
74
+ }));
75
+
76
+ jest.mock('./components/MessageHandler', () => ({
77
+ MessageHandler: () => null,
78
+ }));
79
+
80
+ jest.mock('./components/LiveKitRoomWrapper', () => ({
81
+ LiveKitRoomWrapper: ({ children }: { children: ReactNode }) => <>{children}</>,
82
+ }));
83
+
84
+ describe('ElevenLabsProvider', () => {
85
+ describe('Core Functionality', () => {
86
+ it('should throw error when useConversation is used outside provider', () => {
87
+ const BadComponent = () => {
88
+ useConversation();
89
+ return <TestText>Should not render</TestText>;
90
+ };
91
+
92
+ const originalError = console.error;
93
+ console.error = jest.fn();
94
+
95
+ expect(() => {
96
+ render(<BadComponent />);
97
+ }).toThrow('useConversation must be used within ElevenLabsProvider');
98
+
99
+ console.error = originalError;
100
+ });
101
+
102
+ it('should provide conversation context and all methods when used within provider', () => {
103
+ const TestComponent = () => {
104
+ const conversation = useConversation();
105
+
106
+ // Verify all methods exist and are functions
107
+ expect(typeof conversation.startSession).toBe('function');
108
+ expect(typeof conversation.endSession).toBe('function');
109
+ expect(typeof conversation.sendFeedback).toBe('function');
110
+ expect(typeof conversation.sendContextualUpdate).toBe('function');
111
+ expect(typeof conversation.sendUserMessage).toBe('function');
112
+ expect(typeof conversation.sendUserActivity).toBe('function');
113
+
114
+ // Verify all properties exist with correct types
115
+ expect(typeof conversation.status).toBe('string');
116
+ expect(typeof conversation.isSpeaking).toBe('boolean');
117
+ expect(typeof conversation.canSendFeedback).toBe('boolean');
118
+
119
+ return <TestText>Test passed</TestText>;
120
+ };
121
+
122
+ expect(() => {
123
+ render(
124
+ <ElevenLabsProvider>
125
+ <TestComponent />
126
+ </ElevenLabsProvider>
127
+ );
128
+ }).not.toThrow();
129
+ });
130
+
131
+ it('should allow methods to be called without throwing errors', () => {
132
+ const TestComponent = () => {
133
+ const conversation = useConversation();
134
+
135
+ // Test that all methods can be called safely
136
+ expect(() => conversation.startSession({ agentId: 'test' })).not.toThrow();
137
+ expect(() => conversation.endSession()).not.toThrow();
138
+ expect(() => conversation.sendFeedback(true)).not.toThrow();
139
+ expect(() => conversation.sendContextualUpdate('test context')).not.toThrow();
140
+ expect(() => conversation.sendUserMessage('test message')).not.toThrow();
141
+ expect(() => conversation.sendUserActivity()).not.toThrow();
142
+
143
+ return <TestText>Method tests passed</TestText>;
144
+ };
145
+
146
+ expect(() => {
147
+ render(
148
+ <ElevenLabsProvider>
149
+ <TestComponent />
150
+ </ElevenLabsProvider>
151
+ );
152
+ }).not.toThrow();
153
+ });
154
+
155
+ it('should render children components successfully', () => {
156
+ // Simple test - if rendering doesn't throw, children are successfully rendered
157
+ expect(() => {
158
+ render(
159
+ <ElevenLabsProvider>
160
+ <TestText>Child component rendered</TestText>
161
+ </ElevenLabsProvider>
162
+ );
163
+ }).not.toThrow();
164
+ });
165
+
166
+ it('should provide conversation context with options', () => {
167
+ const TestComponent = () => {
168
+ const conversation = useConversation({
169
+ onConnect: jest.fn(),
170
+ onDisconnect: jest.fn(),
171
+ onError: jest.fn(),
172
+ });
173
+
174
+ expect(conversation).toBeDefined();
175
+ return <TestText>Options test passed</TestText>;
176
+ };
177
+
178
+ expect(() => {
179
+ render(
180
+ <ElevenLabsProvider>
181
+ <TestComponent />
182
+ </ElevenLabsProvider>
183
+ );
184
+ }).not.toThrow();
185
+ });
186
+ });
187
+ });
@@ -0,0 +1,259 @@
1
+ import React from 'react';
2
+ import { createContext, useContext, useState } from 'react';
3
+ import { registerGlobals } from '@livekit/react-native';
4
+ import type { LocalParticipant } from 'livekit-client';
5
+ import type { Callbacks, ConversationConfig, ConversationStatus, ClientToolsConfig } from './types';
6
+ import { constructOverrides } from './utils/overrides';
7
+ import { DEFAULT_SERVER_URL } from './utils/constants';
8
+ import { useConversationCallbacks } from './hooks/useConversationCallbacks';
9
+ import { useConversationSession } from './hooks/useConversationSession';
10
+ import { useLiveKitRoom } from './hooks/useLiveKitRoom';
11
+ import { useMessageSending } from './hooks/useMessageSending';
12
+ import { LiveKitRoomWrapper } from './components/LiveKitRoomWrapper';
13
+
14
+ interface ConversationOptions extends Callbacks, Partial<ClientToolsConfig> {
15
+ serverUrl?: string;
16
+ }
17
+
18
+ interface Conversation {
19
+ startSession: (config: ConversationConfig) => Promise<void>;
20
+ endSession: () => Promise<void>;
21
+ status: ConversationStatus;
22
+ isSpeaking: boolean;
23
+ // TODO: Implement setVolume when LiveKit React Native supports it
24
+ // setVolume: (volume: number) => void;
25
+ canSendFeedback: boolean;
26
+ sendFeedback: (like: boolean) => void;
27
+ sendContextualUpdate: (text: string) => void;
28
+ sendUserMessage: (text: string) => void;
29
+ sendUserActivity: () => void;
30
+ }
31
+
32
+ interface ElevenLabsContextType {
33
+ conversation: Conversation;
34
+ callbacksRef: { current: Callbacks };
35
+ serverUrl: string;
36
+ clientTools: ClientToolsConfig['clientTools'];
37
+ setCallbacks: (callbacks: Callbacks) => void;
38
+ setServerUrl: (url: string) => void;
39
+ setClientTools: (tools: ClientToolsConfig['clientTools']) => void;
40
+ }
41
+
42
+ const ElevenLabsContext = createContext<ElevenLabsContextType | null>(null);
43
+
44
+ export const useConversation = (options: ConversationOptions = {}): Conversation => {
45
+ const context = useContext(ElevenLabsContext);
46
+ if (!context) {
47
+ throw new Error('useConversation must be used within ElevenLabsProvider');
48
+ }
49
+
50
+ const { serverUrl, clientTools, ...callbacks } = options;
51
+
52
+ React.useEffect(() => {
53
+ if (serverUrl) {
54
+ context.setServerUrl(serverUrl);
55
+ }
56
+ }, [context, serverUrl]);
57
+
58
+ if (clientTools) {
59
+ context.setClientTools(clientTools);
60
+ }
61
+
62
+ context.setCallbacks(callbacks);
63
+
64
+ return context.conversation;
65
+ };
66
+
67
+ interface ElevenLabsProviderProps {
68
+ children: React.ReactNode;
69
+ }
70
+
71
+ export const ElevenLabsProvider: React.FC<ElevenLabsProviderProps> = ({ children }) => {
72
+ // Initialize globals on mount
73
+ registerGlobals();
74
+
75
+ // State management
76
+ const [token, setToken] = useState('');
77
+ const [connect, setConnect] = useState(false);
78
+ const [status, setStatus] = useState<ConversationStatus>('disconnected');
79
+ const [serverUrl, setServerUrl] = useState(DEFAULT_SERVER_URL);
80
+ const [roomId, setRoomId] = useState<string>('');
81
+ const [isSpeaking, setIsSpeaking] = useState(false);
82
+ const [canSendFeedback, setCanSendFeedback] = useState(false);
83
+
84
+ // Feedback state tracking
85
+ const currentEventIdRef = React.useRef(1);
86
+ const lastFeedbackEventIdRef = React.useRef(1);
87
+
88
+ // Use ref for clientTools to avoid re-renders (like callbacks)
89
+ const clientToolsRef = React.useRef<ClientToolsConfig['clientTools']>({});
90
+
91
+ // Custom hooks
92
+ const { callbacksRef, setCallbacks: setCallbacksBase } = useConversationCallbacks();
93
+
94
+ // Enhanced setCallbacks that wraps onModeChange to update isSpeaking state
95
+ const setCallbacks = React.useCallback((callbacks: Callbacks) => {
96
+ const wrappedCallbacks = {
97
+ ...callbacks,
98
+ onModeChange: (event: { mode: 'speaking' | 'listening' }) => {
99
+ setIsSpeaking(event.mode === 'speaking');
100
+ callbacks.onModeChange?.(event);
101
+ }
102
+ };
103
+ setCallbacksBase(wrappedCallbacks);
104
+ }, [setCallbacksBase]);
105
+
106
+ const {
107
+ startSession,
108
+ endSession,
109
+ overrides,
110
+ customLlmExtraBody,
111
+ dynamicVariables,
112
+ } = useConversationSession(callbacksRef, setStatus, setConnect, setToken, setRoomId);
113
+
114
+ const {
115
+ roomConnected,
116
+ localParticipant,
117
+ handleParticipantReady,
118
+ handleConnected,
119
+ handleDisconnected,
120
+ handleError,
121
+ } = useLiveKitRoom(callbacksRef, setStatus, roomId);
122
+
123
+ // Enhanced connection handler to initialize feedback state
124
+ const handleConnectedWithFeedback = React.useCallback(() => {
125
+ // Reset feedback state when connecting
126
+ currentEventIdRef.current = 1;
127
+ lastFeedbackEventIdRef.current = 1;
128
+ setCanSendFeedback(false);
129
+ callbacksRef.current.onCanSendFeedbackChange?.({ canSendFeedback: false });
130
+
131
+ handleConnected();
132
+ }, [handleConnected, callbacksRef]);
133
+
134
+ // Enhanced disconnection handler to reset feedback state
135
+ const handleDisconnectedWithFeedback = React.useCallback(() => {
136
+ setCanSendFeedback(false);
137
+ handleDisconnected();
138
+ }, [handleDisconnected]);
139
+
140
+ const { sendMessage } = useMessageSending(status, localParticipant, callbacksRef);
141
+
142
+ const updateCanSendFeedback = React.useCallback(() => {
143
+ const newCanSendFeedback = currentEventIdRef.current !== lastFeedbackEventIdRef.current;
144
+
145
+ if (canSendFeedback !== newCanSendFeedback) {
146
+ setCanSendFeedback(newCanSendFeedback);
147
+ callbacksRef.current.onCanSendFeedbackChange?.({ canSendFeedback: newCanSendFeedback });
148
+ }
149
+ }, [canSendFeedback, callbacksRef]);
150
+
151
+ const sendFeedback = React.useCallback((like: boolean) => {
152
+ if (!canSendFeedback) {
153
+ console.warn(
154
+ lastFeedbackEventIdRef.current === 0
155
+ ? "Cannot send feedback: the conversation has not started yet."
156
+ : "Cannot send feedback: feedback has already been sent for the current response."
157
+ );
158
+ return;
159
+ }
160
+
161
+ const feedbackMessage = {
162
+ type: "feedback",
163
+ score: like ? "like" : "dislike",
164
+ event_id: currentEventIdRef.current,
165
+ };
166
+
167
+ sendMessage(feedbackMessage);
168
+ lastFeedbackEventIdRef.current = currentEventIdRef.current;
169
+ updateCanSendFeedback();
170
+ }, [canSendFeedback, sendMessage, updateCanSendFeedback]);
171
+
172
+ // setVolume placeholder (to be implemented when LiveKit supports it)
173
+ const setVolume = React.useCallback((volume: number) => {
174
+ console.warn('setVolume is not yet implemented in React Native SDK');
175
+ }, []);
176
+
177
+ // Update current event ID for feedback tracking
178
+ const updateCurrentEventId = React.useCallback((eventId: number) => {
179
+ currentEventIdRef.current = eventId;
180
+ updateCanSendFeedback();
181
+ }, [updateCanSendFeedback]);
182
+
183
+ // Handle participant ready with overrides
184
+ const handleParticipantReadyWithOverrides = React.useCallback((participant: LocalParticipant) => {
185
+ handleParticipantReady(participant);
186
+
187
+ if (localParticipant) {
188
+ const overridesEvent = constructOverrides({
189
+ overrides,
190
+ customLlmExtraBody,
191
+ dynamicVariables,
192
+ });
193
+ sendMessage(overridesEvent);
194
+ }
195
+ }, [handleParticipantReady, localParticipant, overrides, customLlmExtraBody, dynamicVariables, sendMessage]);
196
+
197
+ const conversation: Conversation = {
198
+ startSession,
199
+ endSession,
200
+ status,
201
+ isSpeaking,
202
+ // setVolume,
203
+ canSendFeedback,
204
+ sendFeedback,
205
+ sendContextualUpdate: (text: string) => {
206
+ sendMessage({
207
+ type: "contextual_update",
208
+ text,
209
+ });
210
+ },
211
+ sendUserMessage: (text: string) => {
212
+ sendMessage({
213
+ type: "user_message",
214
+ text,
215
+ });
216
+ },
217
+ sendUserActivity: () => {
218
+ sendMessage({
219
+ type: "user_activity",
220
+ });
221
+ },
222
+ };
223
+
224
+ // Create setClientTools function that only updates ref
225
+ const setClientTools = React.useCallback((tools: ClientToolsConfig['clientTools']) => {
226
+ clientToolsRef.current = tools;
227
+ }, []);
228
+
229
+ const contextValue: ElevenLabsContextType = {
230
+ conversation,
231
+ callbacksRef,
232
+ serverUrl,
233
+ clientTools: clientToolsRef.current,
234
+ setCallbacks,
235
+ setServerUrl,
236
+ setClientTools,
237
+ };
238
+
239
+ return (
240
+ <ElevenLabsContext.Provider value={contextValue}>
241
+ <LiveKitRoomWrapper
242
+ serverUrl={serverUrl}
243
+ token={token}
244
+ connect={connect}
245
+ onConnected={handleConnectedWithFeedback}
246
+ onDisconnected={handleDisconnectedWithFeedback}
247
+ onError={handleError}
248
+ roomConnected={roomConnected}
249
+ callbacks={callbacksRef.current}
250
+ onParticipantReady={handleParticipantReadyWithOverrides}
251
+ sendMessage={sendMessage}
252
+ clientTools={clientToolsRef.current}
253
+ updateCurrentEventId={updateCurrentEventId}
254
+ >
255
+ {children}
256
+ </LiveKitRoomWrapper>
257
+ </ElevenLabsContext.Provider>
258
+ );
259
+ };
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import { LiveKitRoom } from '@livekit/react-native';
3
+ import type { LocalParticipant } from 'livekit-client';
4
+ import type { Callbacks, ClientToolsConfig } from '../types';
5
+ import { MessageHandler } from './MessageHandler';
6
+
7
+ interface LiveKitRoomWrapperProps {
8
+ children: React.ReactNode;
9
+ serverUrl: string;
10
+ token: string;
11
+ connect: boolean;
12
+ onConnected: () => void;
13
+ onDisconnected: () => void;
14
+ onError: (error: Error) => void;
15
+ roomConnected: boolean;
16
+ callbacks: Callbacks;
17
+ onParticipantReady: (participant: LocalParticipant) => void;
18
+ sendMessage: (message: unknown) => void;
19
+ clientTools: ClientToolsConfig['clientTools'];
20
+ updateCurrentEventId?: (eventId: number) => void;
21
+ }
22
+
23
+ export const LiveKitRoomWrapper = ({
24
+ children,
25
+ serverUrl,
26
+ token,
27
+ connect,
28
+ onConnected,
29
+ onDisconnected,
30
+ onError,
31
+ roomConnected,
32
+ callbacks,
33
+ onParticipantReady,
34
+ sendMessage,
35
+ clientTools,
36
+ updateCurrentEventId,
37
+ }: LiveKitRoomWrapperProps) => {
38
+ return (
39
+ <LiveKitRoom
40
+ serverUrl={serverUrl}
41
+ token={token}
42
+ connect={connect}
43
+ audio={true}
44
+ video={false}
45
+ options={{
46
+ adaptiveStream: { pixelDensity: 'screen' },
47
+ }}
48
+ onConnected={onConnected}
49
+ onDisconnected={onDisconnected}
50
+ onError={onError}
51
+ >
52
+ <MessageHandler
53
+ onReady={onParticipantReady}
54
+ isConnected={roomConnected}
55
+ callbacks={callbacks}
56
+ sendMessage={sendMessage}
57
+ clientTools={clientTools}
58
+ updateCurrentEventId={updateCurrentEventId}
59
+ />
60
+ {children}
61
+ </LiveKitRoom>
62
+ );
63
+ };
@@ -0,0 +1,127 @@
1
+ import { useEffect } from 'react';
2
+ import { useLocalParticipant, useDataChannel } from '@livekit/react-native';
3
+ import type { LocalParticipant } from 'livekit-client';
4
+ import type { Callbacks, ClientToolsConfig, ClientToolCallEvent } from '../types';
5
+ import React from 'react';
6
+
7
+ interface MessageHandlerProps {
8
+ onReady: (participant: LocalParticipant) => void;
9
+ isConnected: boolean;
10
+ callbacks: Callbacks;
11
+ sendMessage: (message: unknown) => void;
12
+ clientTools?: ClientToolsConfig['clientTools'];
13
+ updateCurrentEventId?: (eventId: number) => void;
14
+ }
15
+
16
+ export const MessageHandler = ({
17
+ onReady,
18
+ isConnected,
19
+ callbacks,
20
+ sendMessage,
21
+ clientTools = {},
22
+ updateCurrentEventId
23
+ }: MessageHandlerProps) => {
24
+ const { localParticipant } = useLocalParticipant();
25
+
26
+ // Track agent response count for synthetic event IDs (WebRTC mode)
27
+ const agentResponseCountRef = React.useRef(1);
28
+
29
+ useEffect(() => {
30
+ if (isConnected && localParticipant) {
31
+ onReady(localParticipant);
32
+ }
33
+ }, [isConnected, localParticipant, onReady]);
34
+
35
+ const handleClientToolCall = async (clientToolCall: ClientToolCallEvent) => {
36
+ if (
37
+ Object.prototype.hasOwnProperty.call(
38
+ clientTools,
39
+ clientToolCall.tool_name
40
+ )
41
+ ) {
42
+ try {
43
+ const result =
44
+ (await clientTools[clientToolCall.tool_name](
45
+ clientToolCall.parameters
46
+ )) ?? "Client tool execution successful."; // default client-tool call response
47
+
48
+ // The API expects result to be a string, so we need to convert it if it's not already a string
49
+ const formattedResult =
50
+ typeof result === "object" ? JSON.stringify(result) : String(result);
51
+
52
+ sendMessage({
53
+ type: "client_tool_result",
54
+ tool_call_id: clientToolCall.tool_call_id,
55
+ result: formattedResult,
56
+ is_error: false,
57
+ });
58
+ } catch (e) {
59
+ const errorMessage = `Client tool execution failed with following error: ${(e as Error)?.message}`;
60
+ callbacks.onError?.(errorMessage, {
61
+ clientToolName: clientToolCall.tool_name,
62
+ });
63
+ sendMessage({
64
+ type: "client_tool_result",
65
+ tool_call_id: clientToolCall.tool_call_id,
66
+ result: `Client tool execution failed: ${(e as Error)?.message}`,
67
+ is_error: true,
68
+ });
69
+ }
70
+ } else {
71
+ if (callbacks.onUnhandledClientToolCall) {
72
+ callbacks.onUnhandledClientToolCall(clientToolCall);
73
+ return;
74
+ }
75
+
76
+ const errorMessage = `Client tool with name ${clientToolCall.tool_name} is not defined on client`;
77
+ callbacks.onError?.(errorMessage, {
78
+ clientToolName: clientToolCall.tool_name,
79
+ });
80
+ sendMessage({
81
+ type: "client_tool_result",
82
+ tool_call_id: clientToolCall.tool_call_id,
83
+ result: errorMessage,
84
+ is_error: true,
85
+ });
86
+ }
87
+ };
88
+
89
+ const _ = useDataChannel((msg) => {
90
+ const decoder = new TextDecoder();
91
+ const message = JSON.parse(decoder.decode(msg.payload));
92
+
93
+ callbacks.onMessage?.({
94
+ message,
95
+ source: msg.from?.isAgent ? 'ai' : 'user',
96
+ });
97
+
98
+ if (msg.from?.isAgent) {
99
+ callbacks.onModeChange?.({
100
+ mode: msg.from?.isSpeaking ? 'speaking' : 'listening'
101
+ });
102
+
103
+ // Track agent responses for feedback (WebRTC mode needs synthetic event IDs)
104
+ if (message.type === "agent_response" && updateCurrentEventId) {
105
+ const eventId = agentResponseCountRef.current++;
106
+ updateCurrentEventId(eventId);
107
+ }
108
+ }
109
+
110
+ switch (message.type) {
111
+ case "ping":
112
+ sendMessage({
113
+ type: "pong",
114
+ event_id: message.ping_event.event_id,
115
+ });
116
+ break;
117
+ case "client_tool_call":
118
+ handleClientToolCall(message.client_tool_call);
119
+ break;
120
+ default:
121
+ callbacks.onDebug?.(message);
122
+ break;
123
+ }
124
+ });
125
+
126
+ return null;
127
+ };