polymorph-sdk 0.2.3 → 0.2.5
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/dist/index.css +1 -1
- package/dist/index.d.ts +46 -22
- package/dist/index.js +6276 -5929
- package/package.json +1 -1
- package/src/ChatThread.tsx +15 -2
- package/src/IdentityForm.tsx +135 -0
- package/src/PolymorphWidget.tsx +44 -18
- package/src/RoomHandler.tsx +22 -2
- package/src/VoiceOverlay.tsx +60 -11
- package/src/WidgetPanel.tsx +86 -60
- package/src/__tests__/IdentityForm.test.tsx +146 -0
- package/src/__tests__/PolymorphWidget.test.tsx +173 -0
- package/src/__tests__/integration.test.ts +58 -0
- package/src/__tests__/usePolymorphSession.test.ts +422 -0
- package/src/index.ts +4 -1
- package/src/styles.module.css +440 -41
- package/src/types.ts +28 -18
- package/src/usePolymorphSession.ts +338 -78
package/package.json
CHANGED
package/src/ChatThread.tsx
CHANGED
|
@@ -61,15 +61,28 @@ export function ChatThread({
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
|
-
<div
|
|
64
|
+
<div
|
|
65
|
+
ref={scrollRef}
|
|
66
|
+
className={styles.chatThread}
|
|
67
|
+
onScroll={handleScroll}
|
|
68
|
+
aria-live="polite"
|
|
69
|
+
aria-relevant="additions"
|
|
70
|
+
>
|
|
65
71
|
{messages.map((msg) => (
|
|
66
72
|
<div
|
|
67
73
|
key={msg.id}
|
|
68
74
|
className={`${styles.messageBubble} ${msg.role === "user" ? styles.userMessage : styles.agentMessage}`}
|
|
69
75
|
style={
|
|
70
|
-
msg.role === "user"
|
|
76
|
+
msg.role === "user"
|
|
77
|
+
? { backgroundColor: primaryColor }
|
|
78
|
+
: msg.senderType === "human"
|
|
79
|
+
? { backgroundColor: `${primaryColor}20` }
|
|
80
|
+
: undefined
|
|
71
81
|
}
|
|
72
82
|
>
|
|
83
|
+
{msg.senderName && (
|
|
84
|
+
<div className={styles.senderLabel}>{msg.senderName}</div>
|
|
85
|
+
)}
|
|
73
86
|
{msg.text}
|
|
74
87
|
{msg.source === "voice" && (
|
|
75
88
|
<div className={styles.voiceLabel}>voice</div>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import styles from "./styles.module.css";
|
|
3
|
+
import type { FieldRequirement, WidgetUser } from "./types";
|
|
4
|
+
|
|
5
|
+
interface IdentityFormProps {
|
|
6
|
+
collectEmail: FieldRequirement;
|
|
7
|
+
collectPhone: FieldRequirement;
|
|
8
|
+
primaryColor: string;
|
|
9
|
+
onSubmit: (user: WidgetUser) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
|
|
13
|
+
const PHONE_DIGITS_RE = /\d/g;
|
|
14
|
+
|
|
15
|
+
export function IdentityForm({
|
|
16
|
+
collectEmail,
|
|
17
|
+
collectPhone,
|
|
18
|
+
primaryColor,
|
|
19
|
+
onSubmit,
|
|
20
|
+
}: IdentityFormProps) {
|
|
21
|
+
const [name, setName] = useState("");
|
|
22
|
+
const [email, setEmail] = useState("");
|
|
23
|
+
const [phone, setPhone] = useState("");
|
|
24
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
25
|
+
|
|
26
|
+
const validate = useCallback(() => {
|
|
27
|
+
const e: Record<string, string> = {};
|
|
28
|
+
if (!name.trim()) e.name = "Name is required";
|
|
29
|
+
if (collectEmail === "required" && !email.trim())
|
|
30
|
+
e.email = "Email is required";
|
|
31
|
+
if (email.trim() && !EMAIL_RE.test(email.trim()))
|
|
32
|
+
e.email = "Invalid email format";
|
|
33
|
+
if (collectPhone === "required" && !phone.trim())
|
|
34
|
+
e.phone = "Phone is required";
|
|
35
|
+
if (phone.trim()) {
|
|
36
|
+
const digitCount = (phone.trim().match(PHONE_DIGITS_RE) || []).length;
|
|
37
|
+
if (digitCount < 7 || digitCount > 15)
|
|
38
|
+
e.phone = "Enter a valid phone number (7–15 digits)";
|
|
39
|
+
}
|
|
40
|
+
// "Either" mode: both optional, at least one required
|
|
41
|
+
if (
|
|
42
|
+
collectEmail === "optional" &&
|
|
43
|
+
collectPhone === "optional" &&
|
|
44
|
+
!email.trim() &&
|
|
45
|
+
!phone.trim()
|
|
46
|
+
)
|
|
47
|
+
e.contact = "Please provide either an email or phone number";
|
|
48
|
+
return e;
|
|
49
|
+
}, [name, email, phone, collectEmail, collectPhone]);
|
|
50
|
+
|
|
51
|
+
const handleSubmit = useCallback(
|
|
52
|
+
(ev: React.FormEvent) => {
|
|
53
|
+
ev.preventDefault();
|
|
54
|
+
const e = validate();
|
|
55
|
+
if (Object.keys(e).length > 0) {
|
|
56
|
+
setErrors(e);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
onSubmit({
|
|
60
|
+
name: name.trim(),
|
|
61
|
+
...(email.trim() && { email: email.trim() }),
|
|
62
|
+
...(phone.trim() && { phone: phone.trim() }),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
[validate, onSubmit, name, email, phone],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<form className={styles.identityForm} onSubmit={handleSubmit}>
|
|
70
|
+
<label className={styles.formField}>
|
|
71
|
+
<span className={styles.formLabel}>
|
|
72
|
+
Name <span className={styles.formError}>*</span>
|
|
73
|
+
</span>
|
|
74
|
+
<input
|
|
75
|
+
className={styles.formInput}
|
|
76
|
+
type="text"
|
|
77
|
+
value={name}
|
|
78
|
+
onChange={(ev) => setName(ev.target.value)}
|
|
79
|
+
placeholder="Your name"
|
|
80
|
+
/>
|
|
81
|
+
{errors.name && <div className={styles.formError}>{errors.name}</div>}
|
|
82
|
+
</label>
|
|
83
|
+
{collectEmail !== "hidden" && (
|
|
84
|
+
<label className={styles.formField}>
|
|
85
|
+
<span className={styles.formLabel}>
|
|
86
|
+
Email{" "}
|
|
87
|
+
{collectEmail === "required" && (
|
|
88
|
+
<span className={styles.formError}>*</span>
|
|
89
|
+
)}
|
|
90
|
+
</span>
|
|
91
|
+
<input
|
|
92
|
+
className={styles.formInput}
|
|
93
|
+
type="email"
|
|
94
|
+
value={email}
|
|
95
|
+
onChange={(ev) => setEmail(ev.target.value)}
|
|
96
|
+
placeholder="you@example.com"
|
|
97
|
+
/>
|
|
98
|
+
{errors.email && (
|
|
99
|
+
<div className={styles.formError}>{errors.email}</div>
|
|
100
|
+
)}
|
|
101
|
+
</label>
|
|
102
|
+
)}
|
|
103
|
+
{collectPhone !== "hidden" && (
|
|
104
|
+
<label className={styles.formField}>
|
|
105
|
+
<span className={styles.formLabel}>
|
|
106
|
+
Phone{" "}
|
|
107
|
+
{collectPhone === "required" && (
|
|
108
|
+
<span className={styles.formError}>*</span>
|
|
109
|
+
)}
|
|
110
|
+
</span>
|
|
111
|
+
<input
|
|
112
|
+
className={styles.formInput}
|
|
113
|
+
type="tel"
|
|
114
|
+
value={phone}
|
|
115
|
+
onChange={(ev) => setPhone(ev.target.value)}
|
|
116
|
+
placeholder="+1 (555) 123-4567"
|
|
117
|
+
/>
|
|
118
|
+
{errors.phone && (
|
|
119
|
+
<div className={styles.formError}>{errors.phone}</div>
|
|
120
|
+
)}
|
|
121
|
+
</label>
|
|
122
|
+
)}
|
|
123
|
+
{errors.contact && (
|
|
124
|
+
<div className={styles.formError}>{errors.contact}</div>
|
|
125
|
+
)}
|
|
126
|
+
<button
|
|
127
|
+
type="submit"
|
|
128
|
+
className={styles.formSubmitButton}
|
|
129
|
+
style={{ background: primaryColor }}
|
|
130
|
+
>
|
|
131
|
+
Start Chat
|
|
132
|
+
</button>
|
|
133
|
+
</form>
|
|
134
|
+
);
|
|
135
|
+
}
|
package/src/PolymorphWidget.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LiveKitRoom } from "@livekit/components-react";
|
|
2
2
|
import { MantineProvider } from "@mantine/core";
|
|
3
3
|
import "@mantine/core/styles.css";
|
|
4
|
-
import { useRef, useState } from "react";
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { RoomHandler } from "./RoomHandler";
|
|
6
6
|
import styles from "./styles.module.css";
|
|
7
7
|
import { buildWidgetTheme } from "./theme";
|
|
@@ -49,37 +49,71 @@ function FabCloseIcon() {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function PolymorphWidget(props: WidgetConfig) {
|
|
52
|
-
const { position = "bottom-right", branding } = props;
|
|
53
52
|
const [open, setOpen] = useState(false);
|
|
54
53
|
const session = usePolymorphSession(props);
|
|
55
|
-
const
|
|
54
|
+
const rc = session.resolvedConfig;
|
|
55
|
+
const primaryColor = rc?.primaryColor || "#171717";
|
|
56
|
+
const position = rc?.position || "bottom-right";
|
|
57
|
+
const darkMode = rc?.darkMode ?? false;
|
|
56
58
|
const theme = buildWidgetTheme(primaryColor);
|
|
59
|
+
const { hasUnread, clearUnread, setPanelOpen } = session;
|
|
57
60
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
58
61
|
|
|
62
|
+
// Disconnect only when the user leaves the page (not when closing the panel)
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const handleBeforeUnload = () => session.disconnect();
|
|
65
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
68
|
+
session.disconnect();
|
|
69
|
+
};
|
|
70
|
+
}, [session.disconnect]);
|
|
71
|
+
|
|
72
|
+
const audio = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
session.isVoiceEnabled
|
|
75
|
+
? {
|
|
76
|
+
echoCancellation: true,
|
|
77
|
+
noiseSuppression: true,
|
|
78
|
+
autoGainControl: true,
|
|
79
|
+
}
|
|
80
|
+
: false,
|
|
81
|
+
[session.isVoiceEnabled],
|
|
82
|
+
);
|
|
83
|
+
|
|
59
84
|
return (
|
|
60
85
|
<div
|
|
61
86
|
ref={rootRef}
|
|
62
87
|
className={`polymorph-widget ${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}
|
|
63
|
-
style={{ colorScheme:
|
|
88
|
+
style={{ colorScheme: darkMode ? "dark" : "light" }}
|
|
64
89
|
>
|
|
65
90
|
<MantineProvider
|
|
66
91
|
theme={theme}
|
|
67
|
-
forceColorScheme={
|
|
92
|
+
forceColorScheme={darkMode ? "dark" : "light"}
|
|
68
93
|
cssVariablesSelector=".polymorph-widget"
|
|
69
94
|
getRootElement={() => rootRef.current ?? undefined}
|
|
70
95
|
>
|
|
71
96
|
<WidgetPanel
|
|
72
|
-
config={props}
|
|
73
97
|
session={session}
|
|
74
|
-
onClose={() =>
|
|
98
|
+
onClose={() => {
|
|
99
|
+
setOpen(false);
|
|
100
|
+
setPanelOpen(false);
|
|
101
|
+
}}
|
|
75
102
|
hidden={!open}
|
|
76
103
|
/>
|
|
77
104
|
<button
|
|
105
|
+
key={session.wiggleKey}
|
|
78
106
|
type="button"
|
|
79
|
-
className={`${styles.fab} ${open ? styles.fabOpen : ""}`}
|
|
107
|
+
className={`${styles.fab} ${open ? styles.fabOpen : ""} ${hasUnread && !open ? styles.fabWiggle : ""}`}
|
|
80
108
|
style={{ backgroundColor: primaryColor }}
|
|
81
|
-
onClick={() =>
|
|
109
|
+
onClick={() => {
|
|
110
|
+
const next = !open;
|
|
111
|
+
setOpen(next);
|
|
112
|
+
setPanelOpen(next);
|
|
113
|
+
if (next) clearUnread();
|
|
114
|
+
}}
|
|
82
115
|
>
|
|
116
|
+
{hasUnread && !open && <span className={styles.notificationDot} />}
|
|
83
117
|
{open ? <FabCloseIcon /> : <ChatIcon />}
|
|
84
118
|
</button>
|
|
85
119
|
{session.roomConnection && (
|
|
@@ -87,15 +121,7 @@ export function PolymorphWidget(props: WidgetConfig) {
|
|
|
87
121
|
token={session.roomConnection.token}
|
|
88
122
|
serverUrl={session.roomConnection.livekitUrl}
|
|
89
123
|
connect={true}
|
|
90
|
-
audio={
|
|
91
|
-
session.isVoiceEnabled
|
|
92
|
-
? {
|
|
93
|
-
echoCancellation: true,
|
|
94
|
-
noiseSuppression: true,
|
|
95
|
-
autoGainControl: true,
|
|
96
|
-
}
|
|
97
|
-
: false
|
|
98
|
-
}
|
|
124
|
+
audio={audio}
|
|
99
125
|
video={false}
|
|
100
126
|
style={{ display: "none" }}
|
|
101
127
|
onDisconnected={session.disconnect}
|
package/src/RoomHandler.tsx
CHANGED
|
@@ -19,6 +19,8 @@ export function RoomHandler({
|
|
|
19
19
|
role: "user" | "agent",
|
|
20
20
|
text: string,
|
|
21
21
|
source: "chat" | "voice",
|
|
22
|
+
senderName?: string,
|
|
23
|
+
senderType?: "human",
|
|
22
24
|
) => void;
|
|
23
25
|
isVoiceEnabled: boolean;
|
|
24
26
|
}) {
|
|
@@ -66,6 +68,8 @@ export function RoomHandler({
|
|
|
66
68
|
const data = JSON.parse(new TextDecoder().decode(payload));
|
|
67
69
|
if (data.type === "chat_response") {
|
|
68
70
|
addMessage("agent", data.text, "chat");
|
|
71
|
+
} else if (data.sender_type === "observer" && data.text) {
|
|
72
|
+
addMessage("agent", data.text, "chat", data.sender_name, "human");
|
|
69
73
|
}
|
|
70
74
|
} catch {
|
|
71
75
|
/* ignore malformed */
|
|
@@ -83,7 +87,23 @@ export function RoomHandler({
|
|
|
83
87
|
const isAgent =
|
|
84
88
|
participant?.kind === ParticipantKind.AGENT ||
|
|
85
89
|
participant?.identity?.includes("agent");
|
|
86
|
-
|
|
90
|
+
// Observer transcriptions come from non-agent remote participants
|
|
91
|
+
// whose identity differs from the local (widget) user
|
|
92
|
+
const isObserver =
|
|
93
|
+
!isAgent &&
|
|
94
|
+
participant &&
|
|
95
|
+
participant.identity !== room.localParticipant.identity;
|
|
96
|
+
if (isObserver) {
|
|
97
|
+
addMessage(
|
|
98
|
+
"agent",
|
|
99
|
+
text,
|
|
100
|
+
"voice",
|
|
101
|
+
participant.name || participant.identity,
|
|
102
|
+
"human",
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
addMessage(isAgent ? "agent" : "user", text, "voice");
|
|
106
|
+
}
|
|
87
107
|
}
|
|
88
108
|
};
|
|
89
109
|
|
|
@@ -95,5 +115,5 @@ export function RoomHandler({
|
|
|
95
115
|
};
|
|
96
116
|
}, [room, addMessage]);
|
|
97
117
|
|
|
98
|
-
return <RoomAudioRenderer
|
|
118
|
+
return isVoiceEnabled ? <RoomAudioRenderer /> : null;
|
|
99
119
|
}
|
package/src/VoiceOverlay.tsx
CHANGED
|
@@ -13,6 +13,8 @@ function VolumeIcon() {
|
|
|
13
13
|
strokeWidth="2"
|
|
14
14
|
strokeLinecap="round"
|
|
15
15
|
strokeLinejoin="round"
|
|
16
|
+
role="img"
|
|
17
|
+
aria-label="Volume on"
|
|
16
18
|
>
|
|
17
19
|
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
|
18
20
|
<path d="M16 9a5 5 0 0 1 0 6" />
|
|
@@ -21,6 +23,28 @@ function VolumeIcon() {
|
|
|
21
23
|
);
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function ScreenShareIcon() {
|
|
27
|
+
return (
|
|
28
|
+
<svg
|
|
29
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
30
|
+
width="14"
|
|
31
|
+
height="14"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
strokeWidth="2"
|
|
36
|
+
strokeLinecap="round"
|
|
37
|
+
strokeLinejoin="round"
|
|
38
|
+
role="img"
|
|
39
|
+
aria-label="Screen share"
|
|
40
|
+
>
|
|
41
|
+
<rect width="20" height="14" x="2" y="3" rx="2" />
|
|
42
|
+
<line x1="8" x2="16" y1="21" y2="21" />
|
|
43
|
+
<line x1="12" x2="12" y1="17" y2="21" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
function VolumeOffIcon() {
|
|
25
49
|
return (
|
|
26
50
|
<svg
|
|
@@ -33,6 +57,8 @@ function VolumeOffIcon() {
|
|
|
33
57
|
strokeWidth="2"
|
|
34
58
|
strokeLinecap="round"
|
|
35
59
|
strokeLinejoin="round"
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label="Volume off"
|
|
36
62
|
>
|
|
37
63
|
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
|
38
64
|
<line x1="22" x2="16" y1="9" y2="15" />
|
|
@@ -44,21 +70,27 @@ function VolumeOffIcon() {
|
|
|
44
70
|
interface VoiceOverlayProps {
|
|
45
71
|
isVoiceEnabled: boolean;
|
|
46
72
|
isMicActive: boolean;
|
|
73
|
+
isScreenSharing?: boolean;
|
|
47
74
|
status: SessionStatus;
|
|
48
75
|
onToggleVoice: () => void;
|
|
76
|
+
onToggleScreenShare?: () => void;
|
|
77
|
+
showScreenShare?: boolean;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
export function VoiceOverlay({
|
|
52
81
|
isVoiceEnabled,
|
|
53
82
|
isMicActive,
|
|
83
|
+
isScreenSharing,
|
|
54
84
|
status,
|
|
55
85
|
onToggleVoice,
|
|
86
|
+
onToggleScreenShare,
|
|
87
|
+
showScreenShare,
|
|
56
88
|
}: VoiceOverlayProps) {
|
|
57
|
-
|
|
89
|
+
const isConnecting = status === "connecting";
|
|
58
90
|
|
|
59
91
|
return (
|
|
60
92
|
<div className={styles.voiceOverlay}>
|
|
61
|
-
{isVoiceEnabled ? (
|
|
93
|
+
{isVoiceEnabled && status === "connected" ? (
|
|
62
94
|
<>
|
|
63
95
|
<span className={styles.voiceBars}>
|
|
64
96
|
<span className={styles.voiceBar} />
|
|
@@ -70,16 +102,33 @@ export function VoiceOverlay({
|
|
|
70
102
|
</span>
|
|
71
103
|
</>
|
|
72
104
|
) : (
|
|
73
|
-
<span className={styles.voiceLabel}>
|
|
105
|
+
<span className={styles.voiceLabel}>
|
|
106
|
+
{isConnecting ? "Connecting..." : "Chat only"}
|
|
107
|
+
</span>
|
|
74
108
|
)}
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
<div className={styles.toggleGroup}>
|
|
110
|
+
{showScreenShare && status === "connected" && (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className={`${styles.voiceToggle} ${isScreenSharing ? styles.screenShareToggleActive : ""}`}
|
|
114
|
+
onClick={onToggleScreenShare}
|
|
115
|
+
title={isScreenSharing ? "Stop sharing" : "Share screen"}
|
|
116
|
+
aria-pressed={isScreenSharing}
|
|
117
|
+
>
|
|
118
|
+
<ScreenShareIcon />
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className={`${styles.voiceToggle} ${isVoiceEnabled && status === "connected" ? styles.voiceToggleActive : ""}`}
|
|
124
|
+
onClick={onToggleVoice}
|
|
125
|
+
disabled={isConnecting}
|
|
126
|
+
title={isVoiceEnabled ? "Turn off voice" : "Turn on voice"}
|
|
127
|
+
aria-pressed={isVoiceEnabled}
|
|
128
|
+
>
|
|
129
|
+
{isVoiceEnabled ? <VolumeIcon /> : <VolumeOffIcon />}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
83
132
|
</div>
|
|
84
133
|
);
|
|
85
134
|
}
|
package/src/WidgetPanel.tsx
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { Room } from "livekit-client";
|
|
2
2
|
import { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { ChatThread } from "./ChatThread";
|
|
4
|
+
import { IdentityForm } from "./IdentityForm";
|
|
4
5
|
import styles from "./styles.module.css";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ChatMessage,
|
|
8
|
+
IdentityCollection,
|
|
9
|
+
ResolvedWidgetConfig,
|
|
10
|
+
SessionStatus,
|
|
11
|
+
WidgetUser,
|
|
12
|
+
} from "./types";
|
|
6
13
|
import { VoiceOverlay } from "./VoiceOverlay";
|
|
7
14
|
|
|
8
15
|
function MicIcon() {
|
|
@@ -88,42 +95,44 @@ function CloseIcon() {
|
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
interface WidgetPanelProps {
|
|
91
|
-
config: WidgetConfig;
|
|
92
98
|
session: {
|
|
93
99
|
status: SessionStatus;
|
|
94
100
|
roomConnection: { token: string; livekitUrl: string } | null;
|
|
95
101
|
messages: ChatMessage[];
|
|
96
102
|
isVoiceEnabled: boolean;
|
|
97
103
|
isMicActive: boolean;
|
|
104
|
+
isScreenSharing: boolean;
|
|
105
|
+
hasObserver: boolean;
|
|
98
106
|
error: string | null;
|
|
107
|
+
needsIdentityForm: boolean;
|
|
108
|
+
identityCollection: IdentityCollection | null;
|
|
109
|
+
resolvedConfig: ResolvedWidgetConfig | null;
|
|
99
110
|
connect: () => Promise<void>;
|
|
100
111
|
disconnect: () => void;
|
|
101
112
|
addMessage: (
|
|
102
113
|
role: "user" | "agent",
|
|
103
114
|
text: string,
|
|
104
115
|
source: "chat" | "voice",
|
|
116
|
+
senderName?: string,
|
|
117
|
+
senderType?: "human",
|
|
105
118
|
) => void;
|
|
106
119
|
sendMessage: (text: string) => void;
|
|
107
120
|
toggleMic: () => void;
|
|
108
121
|
toggleVoice: () => void;
|
|
122
|
+
toggleScreenShare: () => void;
|
|
109
123
|
setRoom: (room: Room | null) => void;
|
|
124
|
+
setUser: (user: WidgetUser) => void;
|
|
110
125
|
};
|
|
111
126
|
onClose: () => void;
|
|
112
127
|
hidden?: boolean;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
|
-
export function WidgetPanel({
|
|
116
|
-
config,
|
|
117
|
-
session,
|
|
118
|
-
onClose,
|
|
119
|
-
hidden,
|
|
120
|
-
}: WidgetPanelProps) {
|
|
130
|
+
export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
|
|
121
131
|
const [inputValue, setInputValue] = useState("");
|
|
122
132
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
.includes("chat-agent");
|
|
133
|
+
const rc = session.resolvedConfig;
|
|
134
|
+
const primaryColor = rc?.primaryColor || "#171717";
|
|
135
|
+
const showVoice = rc?.enableVoice !== false;
|
|
127
136
|
|
|
128
137
|
const handleSend = useCallback(() => {
|
|
129
138
|
if (!inputValue.trim()) return;
|
|
@@ -158,9 +167,9 @@ export function WidgetPanel({
|
|
|
158
167
|
<div className={styles.header}>
|
|
159
168
|
<div>
|
|
160
169
|
<div style={{ fontWeight: 500, fontSize: 16 }}>
|
|
161
|
-
{
|
|
170
|
+
{rc?.title || "Hi there"}
|
|
162
171
|
</div>
|
|
163
|
-
{
|
|
172
|
+
{rc?.subtitle && (
|
|
164
173
|
<div
|
|
165
174
|
style={{
|
|
166
175
|
fontSize: 13,
|
|
@@ -168,7 +177,7 @@ export function WidgetPanel({
|
|
|
168
177
|
marginTop: 2,
|
|
169
178
|
}}
|
|
170
179
|
>
|
|
171
|
-
{
|
|
180
|
+
{rc.subtitle}
|
|
172
181
|
</div>
|
|
173
182
|
)}
|
|
174
183
|
</div>
|
|
@@ -176,56 +185,73 @@ export function WidgetPanel({
|
|
|
176
185
|
<CloseIcon />
|
|
177
186
|
</button>
|
|
178
187
|
</div>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<VoiceOverlay
|
|
186
|
-
isVoiceEnabled={session.isVoiceEnabled}
|
|
187
|
-
isMicActive={session.isMicActive}
|
|
188
|
-
status={session.status}
|
|
189
|
-
onToggleVoice={session.toggleVoice}
|
|
188
|
+
{session.needsIdentityForm && session.identityCollection ? (
|
|
189
|
+
<IdentityForm
|
|
190
|
+
collectEmail={session.identityCollection.collectEmail}
|
|
191
|
+
collectPhone={session.identityCollection.collectPhone}
|
|
192
|
+
primaryColor={primaryColor}
|
|
193
|
+
onSubmit={session.setUser}
|
|
190
194
|
/>
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
195
|
+
) : (
|
|
196
|
+
<>
|
|
197
|
+
<ChatThread
|
|
198
|
+
messages={session.messages}
|
|
199
|
+
primaryColor={primaryColor}
|
|
200
|
+
status={session.status}
|
|
201
|
+
/>
|
|
202
|
+
{(showVoice || session.hasObserver) && (
|
|
203
|
+
<VoiceOverlay
|
|
204
|
+
isVoiceEnabled={session.isVoiceEnabled}
|
|
205
|
+
isMicActive={session.isMicActive}
|
|
206
|
+
isScreenSharing={session.isScreenSharing}
|
|
207
|
+
status={session.status}
|
|
208
|
+
onToggleVoice={session.toggleVoice}
|
|
209
|
+
onToggleScreenShare={session.toggleScreenShare}
|
|
210
|
+
showScreenShare={session.hasObserver}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
{session.error && (
|
|
214
|
+
<div className={styles.errorText}>{session.error}</div>
|
|
215
|
+
)}
|
|
216
|
+
<div className={styles.inputBar}>
|
|
217
|
+
<textarea
|
|
218
|
+
ref={textareaRef}
|
|
219
|
+
className={styles.inputField}
|
|
220
|
+
placeholder={
|
|
221
|
+
session.status === "connecting"
|
|
222
|
+
? "Connecting..."
|
|
223
|
+
: "Type a message..."
|
|
224
|
+
}
|
|
225
|
+
value={inputValue}
|
|
226
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
227
|
+
onInput={handleInput}
|
|
228
|
+
onKeyDown={handleKeyDown}
|
|
229
|
+
disabled={session.status === "connecting"}
|
|
230
|
+
rows={1}
|
|
231
|
+
/>
|
|
220
232
|
<button
|
|
221
233
|
type="button"
|
|
222
|
-
className={
|
|
223
|
-
onClick={
|
|
234
|
+
className={styles.iconButton}
|
|
235
|
+
onClick={handleSend}
|
|
236
|
+
disabled={!inputValue.trim() || session.status === "connecting"}
|
|
224
237
|
>
|
|
225
|
-
|
|
238
|
+
<SendIcon />
|
|
226
239
|
</button>
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
{session.isVoiceEnabled &&
|
|
241
|
+
showVoice &&
|
|
242
|
+
session.status === "connected" && (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
className={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
|
|
246
|
+
onClick={session.toggleMic}
|
|
247
|
+
title={session.isMicActive ? "Mute mic" : "Unmute mic"}
|
|
248
|
+
>
|
|
249
|
+
{session.isMicActive ? <MicIcon /> : <MicOffIcon />}
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
229
255
|
</div>
|
|
230
256
|
);
|
|
231
257
|
}
|