@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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/ElevenLabsProvider.d.ts +22 -0
- package/dist/ElevenLabsProvider.test.d.ts +1 -0
- package/dist/components/LiveKitRoomWrapper.d.ts +20 -0
- package/dist/components/MessageHandler.d.ts +12 -0
- package/dist/hooks/useConversationCallbacks.d.ts +5 -0
- package/dist/hooks/useConversationSession.d.ts +28 -0
- package/dist/hooks/useLiveKitRoom.d.ts +12 -0
- package/dist/hooks/useMessageSending.d.ts +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/lib.js +2 -0
- package/dist/lib.js.map +1 -0
- package/dist/lib.modern.js +2 -0
- package/dist/lib.modern.js.map +1 -0
- package/dist/lib.module.js +2 -0
- package/dist/lib.module.js.map +1 -0
- package/dist/lib.umd.js +2 -0
- package/dist/lib.umd.js.map +1 -0
- package/dist/types.d.ts +109 -0
- package/dist/utils/constants.d.ts +1 -0
- package/dist/utils/overrides.d.ts +2 -0
- package/dist/utils/tokenUtils.d.ts +2 -0
- package/dist/version.d.ts +1 -0
- package/eslint.config.cjs +1 -0
- package/jest.config.cjs +263 -0
- package/jest.rn-mock.js +15 -0
- package/package.json +64 -0
- package/src/ElevenLabsProvider.test.tsx +187 -0
- package/src/ElevenLabsProvider.tsx +259 -0
- package/src/components/LiveKitRoomWrapper.tsx +63 -0
- package/src/components/MessageHandler.tsx +127 -0
- package/src/hooks/useConversationCallbacks.ts +15 -0
- package/src/hooks/useConversationSession.ts +88 -0
- package/src/hooks/useLiveKitRoom.ts +62 -0
- package/src/hooks/useMessageSending.ts +33 -0
- package/src/index.ts +9 -0
- package/src/types.ts +150 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/overrides.ts +40 -0
- package/src/utils/tokenUtils.ts +31 -0
- package/src/version.ts +2 -0
- 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
|
+
};
|