@facepe/widget-mobile-app 1.2.0 → 1.3.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/README.md +51 -0
- package/dist/FacePeMobileAvatar.js +70 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +61 -61
package/README.md
CHANGED
|
@@ -289,5 +289,56 @@ await avatarRef.current?.interrupt();
|
|
|
289
289
|
|
|
290
290
|
---
|
|
291
291
|
|
|
292
|
+
## 🗂️ Conversation data (`onConversationEnd`)
|
|
293
|
+
|
|
294
|
+
When a conversation ends, the SDK hands your app the **whole conversation** in
|
|
295
|
+
one record. It stays in your app — the SDK sends it nowhere; you decide whether
|
|
296
|
+
to show it, save it on your server, or ignore it.
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
<FacePeMobileAvatar
|
|
300
|
+
onConversationEnd={(conversation) => {
|
|
301
|
+
console.log(conversation.durationSec, conversation.transcript.length);
|
|
302
|
+
// e.g. save it on your own server:
|
|
303
|
+
// fetch('https://your-server.example.com/conversations', { method: 'POST', body: JSON.stringify(conversation) });
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
It is called once per conversation: after `stop()`, when the stream drops, or
|
|
309
|
+
when the component unmounts.
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"conversationId": "cm_usage_123",
|
|
314
|
+
"avatar": { "id": "cm_avatar_456", "name": "Dharun" },
|
|
315
|
+
"language": "en",
|
|
316
|
+
"platform": "android",
|
|
317
|
+
"sdkVersion": "1.3.0",
|
|
318
|
+
"startedAt": "2026-10-12T11:40:02.000Z",
|
|
319
|
+
"endedAt": "2026-10-12T11:43:17.000Z",
|
|
320
|
+
"durationSec": 195,
|
|
321
|
+
"endReason": "ended",
|
|
322
|
+
"transcript": [
|
|
323
|
+
{ "role": "assistant", "text": "Welcome! How can I help you today?", "at": "2026-10-12T11:40:05.000Z" },
|
|
324
|
+
{ "role": "user", "text": "Two cheeseburgers please.", "at": "2026-10-12T11:40:11.000Z" }
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
| Field | Meaning |
|
|
330
|
+
|---|---|
|
|
331
|
+
| `conversationId` | The conversation's FacePe id (its usage record) |
|
|
332
|
+
| `avatar` | `{ id, name }` of the avatar |
|
|
333
|
+
| `language` | The language requested for the conversation |
|
|
334
|
+
| `platform` | `web`, `android` or `ios` |
|
|
335
|
+
| `sdkVersion` | The SDK version that recorded it |
|
|
336
|
+
| `startedAt`, `endedAt` | ISO 8601 times |
|
|
337
|
+
| `durationSec` | Length in seconds |
|
|
338
|
+
| `endReason` | `ended` (the user or your code ended it), `disconnected` (the stream dropped), `unmounted` (the component went away) |
|
|
339
|
+
| `transcript` | Every line in order: `{ role: "user" \| "assistant", text, at }` |
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
292
343
|
## 📄 License
|
|
293
344
|
MIT © FacePe AI
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback, } from 'react';
|
|
3
|
-
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity, Platform } from 'react-native';
|
|
4
|
+
import { SDK_VERSION } from './version';
|
|
4
5
|
import { useFacePeMobileContext } from './FacePeMobileProvider';
|
|
5
6
|
// Resolve LiveKit dynamically at runtime so bare/Expo projects can run smoothly.
|
|
6
7
|
// @livekit/react-native brings the native parts (registerGlobals, AudioSession,
|
|
@@ -57,7 +58,7 @@ function ensureLiveKitGlobals(LiveKitRN) {
|
|
|
57
58
|
console.warn('[FacePeMobileAvatar] registerGlobals warning:', err);
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, }, ref) => {
|
|
61
|
+
export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', style, videoStyle, objectFit = 'cover', mirror = false, autoStart = true, enableMic = true, onTranscript, onSpeakStart, onSpeakEnd, onUserSpeakStart, onUserSpeakEnd, onOrderUpdate, onCheckout, onStatusChange, onError, onConversationEnd, }, ref) => {
|
|
61
62
|
const { token, loading: tokenLoading, error: tokenError, apiBaseUrl, refreshToken } = useFacePeMobileContext();
|
|
62
63
|
const [status, setStatus] = useState('idle');
|
|
63
64
|
const [videoTrack, setVideoTrack] = useState(null);
|
|
@@ -72,6 +73,50 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
72
73
|
const streamClientRef = useRef(null);
|
|
73
74
|
const [streamURL, setStreamURL] = useState(null);
|
|
74
75
|
const usageIdRef = useRef(null);
|
|
76
|
+
// The conversation as it happens, for the onConversationEnd record. Lines
|
|
77
|
+
// that carry an id (a line that grows while spoken) replace their earlier version.
|
|
78
|
+
const conversationRef = useRef(null);
|
|
79
|
+
const onConversationEndRef = useRef(onConversationEnd);
|
|
80
|
+
onConversationEndRef.current = onConversationEnd;
|
|
81
|
+
const onTranscriptRef = useRef(onTranscript);
|
|
82
|
+
onTranscriptRef.current = onTranscript;
|
|
83
|
+
// A transcript line: recorded for the conversation record, and passed on.
|
|
84
|
+
const emitLine = (role, text, id) => {
|
|
85
|
+
const conv = conversationRef.current;
|
|
86
|
+
if (conv && text) {
|
|
87
|
+
const same = id ? conv.lines.find((l) => l.id === id) : undefined;
|
|
88
|
+
if (same)
|
|
89
|
+
same.text = text;
|
|
90
|
+
else
|
|
91
|
+
conv.lines.push({ id, role, text, at: new Date().toISOString() });
|
|
92
|
+
}
|
|
93
|
+
onTranscriptRef.current?.({ payload: { role, text, isFinal: true } });
|
|
94
|
+
};
|
|
95
|
+
// Hands the whole conversation to the app, once.
|
|
96
|
+
const finishConversation = (endReason) => {
|
|
97
|
+
const conv = conversationRef.current;
|
|
98
|
+
conversationRef.current = null;
|
|
99
|
+
if (!conv)
|
|
100
|
+
return;
|
|
101
|
+
const endedAt = new Date();
|
|
102
|
+
try {
|
|
103
|
+
onConversationEndRef.current?.({
|
|
104
|
+
conversationId: conv.conversationId,
|
|
105
|
+
avatar: conv.avatar,
|
|
106
|
+
language: conv.language,
|
|
107
|
+
platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web',
|
|
108
|
+
sdkVersion: SDK_VERSION,
|
|
109
|
+
startedAt: conv.startedAt.toISOString(),
|
|
110
|
+
endedAt: endedAt.toISOString(),
|
|
111
|
+
durationSec: Math.max(0, Math.round((endedAt.getTime() - conv.startedAt.getTime()) / 1000)),
|
|
112
|
+
endReason,
|
|
113
|
+
transcript: conv.lines.map(({ role, text, at }) => ({ role, text, at })),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.warn('[FacePeMobileAvatar] onConversationEnd error:', err);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
75
120
|
const mountedRef = useRef(true);
|
|
76
121
|
const isConnectingRef = useRef(false);
|
|
77
122
|
const tokenRef = useRef(token);
|
|
@@ -191,6 +236,13 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
191
236
|
const sessionPayload = await sessionResp.json();
|
|
192
237
|
const sessionInfo = sessionPayload?.data || sessionPayload;
|
|
193
238
|
usageIdRef.current = sessionInfo?.usageId || null;
|
|
239
|
+
conversationRef.current = {
|
|
240
|
+
conversationId: usageIdRef.current,
|
|
241
|
+
avatar: { id: String(sessionInfo?.avatar?.id ?? targetAvatarId), name: String(sessionInfo?.avatar?.name ?? '') },
|
|
242
|
+
language: language ?? null,
|
|
243
|
+
startedAt: new Date(),
|
|
244
|
+
lines: [],
|
|
245
|
+
};
|
|
194
246
|
const connection = sessionInfo?.connection;
|
|
195
247
|
if (connection?.type === 'stream') {
|
|
196
248
|
// Hosted avatar: the stream SDK gives plain MediaStreams (video +
|
|
@@ -212,7 +264,7 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
212
264
|
if (!text || seen.has(key))
|
|
213
265
|
continue;
|
|
214
266
|
seen.add(key);
|
|
215
|
-
|
|
267
|
+
emitLine(String(m.role).includes('user') ? 'user' : 'assistant', text, m?.id ? String(m.id) : undefined);
|
|
216
268
|
}
|
|
217
269
|
});
|
|
218
270
|
client.addListener(ev.USER_SPEECH_STARTED, () => { setIsUserSpeaking(true); onUserSpeakStart?.(); });
|
|
@@ -224,6 +276,7 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
224
276
|
setStreamURL(null);
|
|
225
277
|
setIsUserSpeaking(false);
|
|
226
278
|
updateStatus('disconnected');
|
|
279
|
+
finishConversation('disconnected');
|
|
227
280
|
void endUsage();
|
|
228
281
|
});
|
|
229
282
|
const streams = await client.stream();
|
|
@@ -335,13 +388,13 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
335
388
|
if (type === 'avatar.transcription' || type === 'response.audio_transcript.done') {
|
|
336
389
|
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
337
390
|
if (str)
|
|
338
|
-
|
|
391
|
+
emitLine('assistant', String(str).trim());
|
|
339
392
|
}
|
|
340
393
|
else if (type === 'user.transcription' ||
|
|
341
394
|
type === 'conversation.item.input_audio_transcription.completed') {
|
|
342
395
|
const str = msg.text || msg.transcript || msg.payload?.text;
|
|
343
396
|
if (str)
|
|
344
|
-
|
|
397
|
+
emitLine('user', String(str).trim());
|
|
345
398
|
}
|
|
346
399
|
// Order & checkout intents
|
|
347
400
|
if (type === 'order.update') {
|
|
@@ -358,12 +411,17 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
358
411
|
});
|
|
359
412
|
// 4. Connection state transitions
|
|
360
413
|
room.on(LiveKitRN.RoomEvent.Disconnected, () => {
|
|
361
|
-
if (!mountedRef.current)
|
|
362
|
-
return;
|
|
414
|
+
if (!mountedRef.current || roomRef.current !== room)
|
|
415
|
+
return; // stop() handles its own end
|
|
416
|
+
roomRef.current = null;
|
|
363
417
|
setVideoTrack(null);
|
|
364
418
|
setIsAvatarSpeaking(false);
|
|
365
419
|
setIsUserSpeaking(false);
|
|
366
420
|
updateStatus('disconnected');
|
|
421
|
+
// The stream dropped: close the conversation now, so it is not
|
|
422
|
+
// left open (and billed) until the server's 30-minute limit.
|
|
423
|
+
finishConversation('disconnected');
|
|
424
|
+
void endUsage();
|
|
367
425
|
});
|
|
368
426
|
room.on(LiveKitRN.RoomEvent.Reconnecting, () => {
|
|
369
427
|
updateStatus('reconnecting');
|
|
@@ -423,6 +481,8 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
423
481
|
setStreamURL(null);
|
|
424
482
|
if (failedClient)
|
|
425
483
|
await failedClient.stopStreaming().catch(() => undefined);
|
|
484
|
+
// Opened on the server but never got going: still hand over the record.
|
|
485
|
+
finishConversation('disconnected');
|
|
426
486
|
if (!mountedRef.current)
|
|
427
487
|
return;
|
|
428
488
|
const msg = err?.message || 'Connection failed';
|
|
@@ -452,7 +512,8 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
452
512
|
onError,
|
|
453
513
|
]);
|
|
454
514
|
// Stop streaming session
|
|
455
|
-
const stopStream = useCallback(async () => {
|
|
515
|
+
const stopStream = useCallback(async (reason = 'ended') => {
|
|
516
|
+
finishConversation(reason);
|
|
456
517
|
const room = roomRef.current;
|
|
457
518
|
roomRef.current = null;
|
|
458
519
|
const streamClient = streamClientRef.current;
|
|
@@ -561,7 +622,7 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
|
|
|
561
622
|
}
|
|
562
623
|
return () => {
|
|
563
624
|
mountedRef.current = false;
|
|
564
|
-
void stopStream();
|
|
625
|
+
void stopStream('unmounted');
|
|
565
626
|
};
|
|
566
627
|
}, [autoStart, token, tokenLoading, tokenError]);
|
|
567
628
|
// Handle token loading
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,37 @@ export interface TranscriptPayload {
|
|
|
11
11
|
text: string;
|
|
12
12
|
isFinal?: boolean;
|
|
13
13
|
}
|
|
14
|
+
/** One line of a finished conversation, with the time it was said (ISO 8601). */
|
|
15
|
+
export interface FacePeConversationLine {
|
|
16
|
+
role: 'user' | 'assistant';
|
|
17
|
+
text: string;
|
|
18
|
+
at: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Everything about one conversation, given once when it ends
|
|
22
|
+
* (`onConversationEnd`). It stays in your app: the SDK sends it nowhere — you
|
|
23
|
+
* decide whether to show, keep or send it.
|
|
24
|
+
*/
|
|
25
|
+
export interface FacePeConversation {
|
|
26
|
+
/** The conversation's id (its FacePe usage record), or null if none was opened. */
|
|
27
|
+
conversationId: string | null;
|
|
28
|
+
avatar: {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
/** The language requested for the conversation (ISO 639-1). */
|
|
33
|
+
language: string | null;
|
|
34
|
+
platform: 'android' | 'ios' | 'web';
|
|
35
|
+
sdkVersion: string;
|
|
36
|
+
/** When it started and ended (ISO 8601). */
|
|
37
|
+
startedAt: string;
|
|
38
|
+
endedAt: string;
|
|
39
|
+
durationSec: number;
|
|
40
|
+
/** `ended`: by `stop()`; `disconnected`: the stream dropped; `unmounted`: the component went away. */
|
|
41
|
+
endReason: 'ended' | 'disconnected' | 'unmounted';
|
|
42
|
+
/** Every line, in order (a line that grew while being spoken appears once, complete). */
|
|
43
|
+
transcript: FacePeConversationLine[];
|
|
44
|
+
}
|
|
14
45
|
export interface OrderItem {
|
|
15
46
|
name: string;
|
|
16
47
|
qty: number;
|
|
@@ -69,6 +100,8 @@ export interface FacePeMobileAvatarProps {
|
|
|
69
100
|
onCheckout?: () => void;
|
|
70
101
|
/** Called when stream connection status changes */
|
|
71
102
|
onStatusChange?: (status: FacePeConnectionStatus) => void;
|
|
103
|
+
/** Called once when a conversation ends, with the whole conversation (stays in your app) */
|
|
104
|
+
onConversationEnd?: (conversation: FacePeConversation) => void;
|
|
72
105
|
/** Called when an error occurs */
|
|
73
106
|
onError?: (event: {
|
|
74
107
|
code: string;
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@facepe/widget-mobile-app",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
|
|
5
|
-
"main": "./dist/index.js",
|
|
6
|
-
"module": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"README.md"
|
|
11
|
-
],
|
|
12
|
-
"publishConfig": {
|
|
13
|
-
"access": "public"
|
|
14
|
-
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
"facepe",
|
|
17
|
-
"avatar",
|
|
18
|
-
"ai-avatar",
|
|
19
|
-
"livekit",
|
|
20
|
-
"react-native",
|
|
21
|
-
"ios",
|
|
22
|
-
"android",
|
|
23
|
-
"webrtc",
|
|
24
|
-
"streaming",
|
|
25
|
-
"digital-human"
|
|
26
|
-
],
|
|
27
|
-
"author": "FacePe AI",
|
|
28
|
-
"license": "MIT",
|
|
29
|
-
"scripts": {
|
|
30
|
-
"lint": "tsc --noEmit",
|
|
31
|
-
"build": "tsc"
|
|
32
|
-
},
|
|
33
|
-
"peerDependencies": {
|
|
34
|
-
"@livekit/react-native": ">=2.0.0",
|
|
35
|
-
"@livekit/react-native-webrtc": ">=124.0.0",
|
|
36
|
-
"livekit-client": ">=2.0.0",
|
|
37
|
-
"react": ">=18.0.0",
|
|
38
|
-
"react-native": ">=0.70.0",
|
|
39
|
-
"@anam-ai/js-sdk": ">=4.27.0"
|
|
40
|
-
},
|
|
41
|
-
"peerDependenciesMeta": {
|
|
42
|
-
"@livekit/react-native": {
|
|
43
|
-
"optional": true
|
|
44
|
-
},
|
|
45
|
-
"@livekit/react-native-webrtc": {
|
|
46
|
-
"optional": true
|
|
47
|
-
},
|
|
48
|
-
"livekit-client": {
|
|
49
|
-
"optional": true
|
|
50
|
-
},
|
|
51
|
-
"@anam-ai/js-sdk": {
|
|
52
|
-
"optional": true
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
"devDependencies": {
|
|
56
|
-
"@types/react": "^18.3.3",
|
|
57
|
-
"react": "18.3.1",
|
|
58
|
-
"react-native": "0.74.0",
|
|
59
|
-
"typescript": "^5.4.5"
|
|
60
|
-
}
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@facepe/widget-mobile-app",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"facepe",
|
|
17
|
+
"avatar",
|
|
18
|
+
"ai-avatar",
|
|
19
|
+
"livekit",
|
|
20
|
+
"react-native",
|
|
21
|
+
"ios",
|
|
22
|
+
"android",
|
|
23
|
+
"webrtc",
|
|
24
|
+
"streaming",
|
|
25
|
+
"digital-human"
|
|
26
|
+
],
|
|
27
|
+
"author": "FacePe AI",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"lint": "tsc --noEmit",
|
|
31
|
+
"build": "tsc"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@livekit/react-native": ">=2.0.0",
|
|
35
|
+
"@livekit/react-native-webrtc": ">=124.0.0",
|
|
36
|
+
"livekit-client": ">=2.0.0",
|
|
37
|
+
"react": ">=18.0.0",
|
|
38
|
+
"react-native": ">=0.70.0",
|
|
39
|
+
"@anam-ai/js-sdk": ">=4.27.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@livekit/react-native": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@livekit/react-native-webrtc": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"livekit-client": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@anam-ai/js-sdk": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/react": "^18.3.3",
|
|
57
|
+
"react": "18.3.1",
|
|
58
|
+
"react-native": "0.74.0",
|
|
59
|
+
"typescript": "^5.4.5"
|
|
60
|
+
}
|
|
61
|
+
}
|