@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 +20 -0
- package/dist/FacePeMobileAvatar.js +90 -24
- package/dist/types.d.ts +25 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 })) : (
|
|
649
|
-
|
|
650
|
-
|
|
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.
|
|
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.
|
|
2
|
+
export const SDK_VERSION = '1.4.0';
|
package/package.json
CHANGED