@facepe/widget-mobile-app 1.3.0 → 1.4.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
@@ -253,6 +253,7 @@ const styles = StyleSheet.create({
253
253
  | `objectFit` | `'cover' \| 'contain'` | `'cover'` | Video aspect scaling mode. |
254
254
  | `mirror` | `boolean` | `false` | Mirror video orientation. |
255
255
  | `autoStart` | `boolean` | `true` | Automatically connect and start stream on mount. |
256
+ | `posterUrl` | `string \| null` | avatar's portrait | Picture shown until the video starts. Defaults to the avatar's portrait from FacePe; pass `null` for none. |
256
257
  | `enableMic` | `boolean` | `true` | Automatically enable microphone on connect. |
257
258
  | `onTranscript` | `function` | `undefined` | Fires when speech transcription arrives. |
258
259
  | `onSpeakStart` | `function` | `undefined` | Fires when avatar starts speaking. |
@@ -337,6 +338,25 @@ when the component unmounts.
337
338
  | `durationSec` | Length in seconds |
338
339
  | `endReason` | `ended` (the user or your code ended it), `disconnected` (the stream dropped), `unmounted` (the component went away) |
339
340
  | `transcript` | Every line in order: `{ role: "user" \| "assistant", text, at }` |
341
+ | `form` | For a form avatar: `{ title, complete, fields: [{ label, question, required, value }] }`; `null` otherwise |
342
+
343
+ For an avatar that **fills a form**, the record also carries the form as the
344
+ conversation filled it: each question with the customer's answer (`value` is
345
+ `null` for a question that was not answered). FacePe reads the answers from the
346
+ conversation when it ends, so the record arrives a few seconds after the call.
347
+ For other avatars `form` is `null`.
348
+
349
+ ```json
350
+ "form": {
351
+ "title": "Customer Intake Form",
352
+ "complete": true,
353
+ "fields": [
354
+ { "label": "Name", "question": "Could you please tell me your full name?", "required": true, "value": "Tony" },
355
+ { "label": "Phone", "question": "What is the best phone number to reach you?", "required": true, "value": "9659390715" },
356
+ { "label": "Email", "question": "May I have your email address?", "required": false, "value": "tony@gmail.com" }
357
+ ]
358
+ }
359
+ ```
340
360
 
341
361
  ---
342
362
 
@@ -1,6 +1,6 @@
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, Platform } from 'react-native';
3
+ import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity, Platform, Image } from 'react-native';
4
4
  import { SDK_VERSION } from './version';
5
5
  import { useFacePeMobileContext } from './FacePeMobileProvider';
6
6
  // Resolve LiveKit dynamically at runtime so bare/Expo projects can run smoothly.
@@ -58,8 +58,44 @@ function ensureLiveKitGlobals(LiveKitRN) {
58
58
  console.warn('[FacePeMobileAvatar] registerGlobals warning:', err);
59
59
  }
60
60
  }
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
+ * Speech recognition writes a spoken phone number in parts ("9659-3907-1526").
63
+ * Long numbers are shown as plain digits; short ones ("2-3") are left as said.
64
+ */
65
+ const joinNumberParts = (text) => text.replace(/\d+(?:[ \t]*[-\u2010\u2011\u2013][ \t]*\d+)+/g, (m) => {
66
+ const digits = m.replace(/\D/g, '');
67
+ return digits.length >= 7 ? digits : m;
68
+ });
69
+ 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, posterUrl, }, ref) => {
62
70
  const { token, loading: tokenLoading, error: tokenError, apiBaseUrl, refreshToken } = useFacePeMobileContext();
71
+ // The avatar's portrait, shown until its video arrives. Looked up from the
72
+ // avatars this token may use, unless the app gave one (or null for none).
73
+ const [autoPoster, setAutoPoster] = useState(null);
74
+ useEffect(() => {
75
+ if (posterUrl !== undefined || !token)
76
+ return;
77
+ let cancelled = false;
78
+ void (async () => {
79
+ try {
80
+ const res = await fetch(`${apiBaseUrl.replace(/\/+$/, '')}/avatars`, {
81
+ headers: { Authorization: `Bearer ${token}` },
82
+ });
83
+ if (!res.ok)
84
+ return;
85
+ const avatars = (await res.json())?.data?.avatars ?? [];
86
+ const avatar = avatarKey ? avatars.find((a) => a.id === avatarKey) : avatars[0];
87
+ if (!cancelled)
88
+ setAutoPoster(avatar?.previewUrl ?? null);
89
+ }
90
+ catch {
91
+ /* no picture: the plain background stays */
92
+ }
93
+ })();
94
+ return () => {
95
+ cancelled = true;
96
+ };
97
+ }, [token, apiBaseUrl, avatarKey, posterUrl]);
98
+ const poster = posterUrl !== undefined ? posterUrl : autoPoster;
63
99
  const [status, setStatus] = useState('idle');
64
100
  const [videoTrack, setVideoTrack] = useState(null);
65
101
  const [isMicEnabled, setIsMicEnabled] = useState(enableMic);
@@ -81,7 +117,8 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
81
117
  const onTranscriptRef = useRef(onTranscript);
82
118
  onTranscriptRef.current = onTranscript;
83
119
  // A transcript line: recorded for the conversation record, and passed on.
84
- const emitLine = (role, text, id) => {
120
+ const emitLine = (role, said, id) => {
121
+ const text = joinNumberParts(said);
85
122
  const conv = conversationRef.current;
86
123
  if (conv && text) {
87
124
  const same = id ? conv.lines.find((l) => l.id === id) : undefined;
@@ -92,30 +129,51 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
92
129
  }
93
130
  onTranscriptRef.current?.({ payload: { role, text, isFinal: true } });
94
131
  };
95
- // Hands the whole conversation to the app, once.
132
+ // Hands the whole conversation to the app, once. For a form avatar it first
133
+ // asks FacePe for the form as the conversation filled it (a few seconds).
96
134
  const finishConversation = (endReason) => {
97
135
  const conv = conversationRef.current;
98
136
  conversationRef.current = null;
99
137
  if (!conv)
100
138
  return;
101
139
  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
- }
140
+ const transcript = conv.lines.map(({ role, text, at }) => ({ role, text, at }));
141
+ void (async () => {
142
+ let form = null;
143
+ const token = tokenRef.current;
144
+ if (conv.conversationId && transcript.length > 0 && token) {
145
+ try {
146
+ const res = await fetch(`${apiBaseUrl.replace(/\/+$/, '')}/avatar-sessions/${encodeURIComponent(conv.conversationId)}/form`, {
147
+ method: 'POST',
148
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
149
+ body: JSON.stringify({ transcript: transcript.map(({ role, text }) => ({ role, text })) }),
150
+ });
151
+ if (res.ok)
152
+ form = (await res.json())?.data?.form ?? null;
153
+ }
154
+ catch {
155
+ /* offline: the record goes without the form */
156
+ }
157
+ }
158
+ try {
159
+ onConversationEndRef.current?.({
160
+ conversationId: conv.conversationId,
161
+ avatar: conv.avatar,
162
+ language: conv.language,
163
+ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web',
164
+ sdkVersion: SDK_VERSION,
165
+ startedAt: conv.startedAt.toISOString(),
166
+ endedAt: endedAt.toISOString(),
167
+ durationSec: Math.max(0, Math.round((endedAt.getTime() - conv.startedAt.getTime()) / 1000)),
168
+ endReason,
169
+ transcript,
170
+ form,
171
+ });
172
+ }
173
+ catch (err) {
174
+ console.warn('[FacePeMobileAvatar] onConversationEnd error:', err);
175
+ }
176
+ })();
119
177
  };
120
178
  const mountedRef = useRef(true);
121
179
  const isConnectingRef = useRef(false);
@@ -645,9 +703,9 @@ export const FacePeMobileAvatar = forwardRef(({ avatarKey, language = 'en', styl
645
703
  // A plain object, not an array: VideoView spreads `style` into its
646
704
  // container ({ ...style }), and spreading an array loses every
647
705
  // size — the video got no height and stayed invisible.
648
- style: StyleSheet.flatten([styles.video, videoStyle]), objectFit: objectFit, mirror: mirror })) : (_jsx(View, { style: [styles.container, styles.center], children: status === 'connecting' || status === 'reconnecting' ? (_jsxs(_Fragment, { children: [_jsx(ActivityIndicator, { size: "large", color: "#8B5CF6" }), _jsx(Text, { style: styles.loadingText, children: status === 'connecting'
649
- ? 'Connecting to FacePe AI Avatar…'
650
- : 'Reconnecting stream…' })] })) : status === 'error' ? (_jsxs(_Fragment, { children: [_jsx(Text, { style: styles.errorTitle, children: "Connection Failed" }), _jsx(Text, { style: styles.errorText, children: errorMessage || 'Unable to connect to stream' }), _jsx(TouchableOpacity, { style: styles.retryButton, onPress: () => void startStream(), children: _jsx(Text, { style: styles.retryText, children: "Reconnect" }) })] })) : (_jsx(TouchableOpacity, { style: styles.startButton, onPress: () => void startStream(), children: _jsx(Text, { style: styles.startText, children: "Start Avatar Session" }) })) })), status === 'connected' && (isAvatarSpeaking || isUserSpeaking) && (_jsxs(View, { style: styles.speakerPill, children: [_jsx(View, { style: [
706
+ style: StyleSheet.flatten([styles.video, videoStyle]), objectFit: objectFit, mirror: mirror })) : (_jsxs(View, { style: [styles.container, styles.center, !!poster && (status === 'idle' || status === 'disconnected') && styles.overPoster], children: [poster && _jsx(Image, { source: { uri: poster }, style: StyleSheet.absoluteFill, resizeMode: "cover" }), poster && status !== 'idle' && status !== 'disconnected' && _jsx(View, { style: [StyleSheet.absoluteFill, styles.dim] }), status === 'connecting' || status === 'reconnecting' || status === 'connected' ? (
707
+ // connected: the stream is up and the first video frame is on its way.
708
+ _jsxs(_Fragment, { children: [_jsx(ActivityIndicator, { size: "large", color: "#8B5CF6" }), _jsx(Text, { style: styles.loadingText, children: status === 'reconnecting' ? 'Reconnecting stream…' : 'Connecting to FacePe AI Avatar…' })] })) : status === 'error' ? (_jsxs(_Fragment, { children: [_jsx(Text, { style: styles.errorTitle, children: "Connection Failed" }), _jsx(Text, { style: styles.errorText, children: errorMessage || 'Unable to connect to stream' }), _jsx(TouchableOpacity, { style: styles.retryButton, onPress: () => void startStream(), children: _jsx(Text, { style: styles.retryText, children: "Reconnect" }) })] })) : (_jsx(TouchableOpacity, { style: styles.startButton, onPress: () => void startStream(), children: _jsx(Text, { style: styles.startText, children: "Start Avatar Session" }) }))] })), status === 'connected' && (isAvatarSpeaking || isUserSpeaking) && (_jsxs(View, { style: styles.speakerPill, children: [_jsx(View, { style: [
651
709
  styles.speakerDot,
652
710
  isAvatarSpeaking ? styles.dotAvatar : styles.dotUser,
653
711
  ] }), _jsx(Text, { style: styles.speakerText, children: isAvatarSpeaking ? 'Avatar speaking' : 'Listening to you…' })] }))] }));
@@ -667,6 +725,14 @@ const styles = StyleSheet.create({
667
725
  width: '100%',
668
726
  height: '100%',
669
727
  },
728
+ // Over a portrait, keep the face clear: the button sits at the bottom.
729
+ overPoster: {
730
+ justifyContent: 'flex-end',
731
+ paddingBottom: 28,
732
+ },
733
+ dim: {
734
+ backgroundColor: 'rgba(15, 23, 42, 0.45)',
735
+ },
670
736
  center: {
671
737
  justifyContent: 'center',
672
738
  alignItems: 'center',
package/dist/types.d.ts CHANGED
@@ -22,6 +22,20 @@ export interface FacePeConversationLine {
22
22
  * (`onConversationEnd`). It stays in your app: the SDK sends it nowhere — you
23
23
  * decide whether to show, keep or send it.
24
24
  */
25
+ /** One question of a form avatar, with the customer's answer (null: not answered). */
26
+ export interface FacePeFormAnswer {
27
+ label: string;
28
+ question: string;
29
+ required: boolean;
30
+ value: string | null;
31
+ }
32
+ /** A form avatar's form, filled from the conversation. */
33
+ export interface FacePeFilledForm {
34
+ title: string;
35
+ /** Every required question has an answer. */
36
+ complete: boolean;
37
+ fields: FacePeFormAnswer[];
38
+ }
25
39
  export interface FacePeConversation {
26
40
  /** The conversation's id (its FacePe usage record), or null if none was opened. */
27
41
  conversationId: string | null;
@@ -41,6 +55,12 @@ export interface FacePeConversation {
41
55
  endReason: 'ended' | 'disconnected' | 'unmounted';
42
56
  /** Every line, in order (a line that grew while being spoken appears once, complete). */
43
57
  transcript: FacePeConversationLine[];
58
+ /**
59
+ * For an avatar that fills a form: its questions with the customer's answers,
60
+ * read from the conversation. Null for other avatars, or when the answers
61
+ * could not be read.
62
+ */
63
+ form: FacePeFilledForm | null;
44
64
  }
45
65
  export interface OrderItem {
46
66
  name: string;
@@ -100,6 +120,11 @@ export interface FacePeMobileAvatarProps {
100
120
  onCheckout?: () => void;
101
121
  /** Called when stream connection status changes */
102
122
  onStatusChange?: (status: FacePeConnectionStatus) => void;
123
+ /**
124
+ * Picture shown until the video starts (idle, connecting, errors). By default
125
+ * the avatar's own portrait from FacePe; pass your own URL, or null for none.
126
+ */
127
+ posterUrl?: string | null;
103
128
  /** Called once when a conversation ends, with the whole conversation (stays in your app) */
104
129
  onConversationEnd?: (conversation: FacePeConversation) => void;
105
130
  /** Called when an error occurs */
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported in conversation records. Keep in step with package.json. */
2
- export declare const SDK_VERSION = "1.3.0";
2
+ export declare const SDK_VERSION = "1.4.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported in conversation records. Keep in step with package.json. */
2
- export const SDK_VERSION = '1.3.0';
2
+ export const SDK_VERSION = '1.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@facepe/widget-mobile-app",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "FacePe AI 3D Video Avatar Native Live Streaming SDK for React Native (iOS & Android)",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",