@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 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
- onTranscript?.({ payload: { role: String(m.role).includes('user') ? 'user' : 'assistant', text, isFinal: true } });
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
- onTranscript?.({ payload: { role: 'assistant', text: String(str).trim() } });
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
- onTranscript?.({ payload: { role: 'user', text: String(str).trim() } });
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
@@ -2,3 +2,4 @@ export * from './types';
2
2
  export * from './FacePeMobileProvider';
3
3
  export * from './FacePeMobileAvatar';
4
4
  export * from './useFacePeAvatar';
5
+ export { SDK_VERSION } from './version';
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export * from './types';
2
2
  export * from './FacePeMobileProvider';
3
3
  export * from './FacePeMobileAvatar';
4
4
  export * from './useFacePeAvatar';
5
+ export { SDK_VERSION } from './version';
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;
@@ -0,0 +1,2 @@
1
+ /** This package's version, reported in conversation records. Keep in step with package.json. */
2
+ export declare const SDK_VERSION = "1.3.0";
@@ -0,0 +1,2 @@
1
+ /** This package's version, reported in conversation records. Keep in step with package.json. */
2
+ export const SDK_VERSION = '1.3.0';
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "@facepe/widget-mobile-app",
3
- "version": "1.2.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
- }
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
+ }