polymorph-sdk 0.2.0 → 0.2.2
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 +1 -2
- package/dist/index.css +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3313 -3243
- package/package.json +6 -2
- package/src/ChatThread.tsx +50 -6
- package/src/PolymorphWidget.tsx +70 -8
- package/src/RoomHandler.tsx +99 -0
- package/src/VoiceOverlay.tsx +22 -2
- package/src/WidgetPanel.tsx +129 -95
- package/src/__tests__/exports.test.ts +13 -0
- package/src/index.ts +6 -1
- package/src/styles.module.css +92 -28
- package/src/types.ts +5 -5
- package/src/usePolymorphSession.ts +110 -41
package/src/styles.module.css
CHANGED
|
@@ -4,15 +4,19 @@
|
|
|
4
4
|
display: flex;
|
|
5
5
|
flex-direction: column;
|
|
6
6
|
align-items: flex-end;
|
|
7
|
+
justify-content: flex-end;
|
|
7
8
|
gap: 12px;
|
|
9
|
+
pointer-events: none;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
.bottomRight {
|
|
13
|
+
top: 24px;
|
|
11
14
|
bottom: 24px;
|
|
12
15
|
right: 24px;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
.bottomLeft {
|
|
19
|
+
top: 24px;
|
|
16
20
|
bottom: 24px;
|
|
17
21
|
left: 24px;
|
|
18
22
|
align-items: flex-start;
|
|
@@ -28,8 +32,11 @@
|
|
|
28
32
|
align-items: center;
|
|
29
33
|
justify-content: center;
|
|
30
34
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
31
|
-
transition:
|
|
35
|
+
transition:
|
|
36
|
+
transform 150ms ease,
|
|
37
|
+
box-shadow 150ms ease;
|
|
32
38
|
color: white;
|
|
39
|
+
pointer-events: auto;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
.fab:hover {
|
|
@@ -43,15 +50,18 @@
|
|
|
43
50
|
display: flex;
|
|
44
51
|
flex-direction: column;
|
|
45
52
|
border-radius: 16px;
|
|
46
|
-
box-shadow:
|
|
53
|
+
box-shadow:
|
|
54
|
+
0 4px 24px rgba(0, 0, 0, 0.08),
|
|
55
|
+
0 12px 48px rgba(0, 0, 0, 0.12);
|
|
47
56
|
overflow: hidden;
|
|
48
|
-
background:
|
|
49
|
-
|
|
57
|
+
background: var(--mantine-color-body);
|
|
58
|
+
color: var(--mantine-color-text);
|
|
59
|
+
pointer-events: auto;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
.header {
|
|
53
63
|
padding: 16px 16px 12px;
|
|
54
|
-
border-bottom: 1px solid
|
|
64
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
55
65
|
display: flex;
|
|
56
66
|
justify-content: space-between;
|
|
57
67
|
align-items: flex-start;
|
|
@@ -79,8 +89,8 @@
|
|
|
79
89
|
|
|
80
90
|
.agentMessage {
|
|
81
91
|
align-self: flex-start;
|
|
82
|
-
background:
|
|
83
|
-
color:
|
|
92
|
+
background: var(--mantine-color-gray-light);
|
|
93
|
+
color: var(--mantine-color-text);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
.userMessage {
|
|
@@ -100,8 +110,8 @@
|
|
|
100
110
|
align-items: center;
|
|
101
111
|
gap: 8px;
|
|
102
112
|
font-size: 13px;
|
|
103
|
-
color:
|
|
104
|
-
border-top: 1px solid
|
|
113
|
+
color: var(--mantine-color-dimmed);
|
|
114
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
.voiceBars {
|
|
@@ -118,13 +128,27 @@
|
|
|
118
128
|
animation: voiceBar 1.2s ease-in-out infinite;
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
.voiceBar:nth-child(1) {
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
.voiceBar:nth-child(1) {
|
|
132
|
+
height: 6px;
|
|
133
|
+
animation-delay: 0s;
|
|
134
|
+
}
|
|
135
|
+
.voiceBar:nth-child(2) {
|
|
136
|
+
height: 12px;
|
|
137
|
+
animation-delay: 0.2s;
|
|
138
|
+
}
|
|
139
|
+
.voiceBar:nth-child(3) {
|
|
140
|
+
height: 8px;
|
|
141
|
+
animation-delay: 0.4s;
|
|
142
|
+
}
|
|
124
143
|
|
|
125
144
|
@keyframes voiceBar {
|
|
126
|
-
0%,
|
|
127
|
-
|
|
145
|
+
0%,
|
|
146
|
+
100% {
|
|
147
|
+
transform: scaleY(1);
|
|
148
|
+
}
|
|
149
|
+
50% {
|
|
150
|
+
transform: scaleY(0.4);
|
|
151
|
+
}
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
.voiceToggle {
|
|
@@ -132,33 +156,33 @@
|
|
|
132
156
|
width: 28px;
|
|
133
157
|
height: 28px;
|
|
134
158
|
border-radius: 14px;
|
|
135
|
-
border: 1px solid
|
|
159
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
136
160
|
cursor: pointer;
|
|
137
161
|
display: flex;
|
|
138
162
|
align-items: center;
|
|
139
163
|
justify-content: center;
|
|
140
|
-
background:
|
|
141
|
-
color:
|
|
164
|
+
background: var(--mantine-color-gray-light);
|
|
165
|
+
color: var(--mantine-color-dimmed);
|
|
142
166
|
transition: all 150ms ease;
|
|
143
167
|
}
|
|
144
168
|
|
|
145
169
|
.voiceToggle:hover {
|
|
146
|
-
background:
|
|
170
|
+
background: var(--mantine-color-gray-light-hover);
|
|
147
171
|
}
|
|
148
172
|
|
|
149
173
|
.voiceToggleActive {
|
|
150
|
-
background: #dcfce7;
|
|
151
|
-
border-color: #bbf7d0;
|
|
152
|
-
color: #16a34a;
|
|
174
|
+
background: light-dark(#dcfce7, #1a3a2a);
|
|
175
|
+
border-color: light-dark(#bbf7d0, #2a5a3a);
|
|
176
|
+
color: light-dark(#16a34a, #4ade80);
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
.voiceToggleActive:hover {
|
|
156
|
-
background: #bbf7d0;
|
|
180
|
+
background: light-dark(#bbf7d0, #2a5a3a);
|
|
157
181
|
}
|
|
158
182
|
|
|
159
183
|
.inputBar {
|
|
160
184
|
padding: 12px 16px;
|
|
161
|
-
border-top: 1px solid
|
|
185
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
162
186
|
display: flex;
|
|
163
187
|
gap: 8px;
|
|
164
188
|
align-items: center;
|
|
@@ -166,16 +190,18 @@
|
|
|
166
190
|
|
|
167
191
|
.inputField {
|
|
168
192
|
flex: 1;
|
|
169
|
-
border: 1px solid
|
|
193
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
170
194
|
border-radius: 8px;
|
|
171
195
|
padding: 8px 12px;
|
|
172
196
|
font-size: 14px;
|
|
173
197
|
outline: none;
|
|
174
198
|
font-family: inherit;
|
|
199
|
+
background: var(--mantine-color-body);
|
|
200
|
+
color: var(--mantine-color-text);
|
|
175
201
|
}
|
|
176
202
|
|
|
177
203
|
.inputField:focus {
|
|
178
|
-
border-color:
|
|
204
|
+
border-color: var(--mantine-color-dimmed);
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
.iconButton {
|
|
@@ -188,12 +214,14 @@
|
|
|
188
214
|
align-items: center;
|
|
189
215
|
justify-content: center;
|
|
190
216
|
background: transparent;
|
|
191
|
-
color:
|
|
192
|
-
transition:
|
|
217
|
+
color: var(--mantine-color-dimmed);
|
|
218
|
+
transition:
|
|
219
|
+
background 150ms ease,
|
|
220
|
+
color 150ms ease;
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
.iconButton:hover {
|
|
196
|
-
background:
|
|
224
|
+
background: var(--mantine-color-gray-light);
|
|
197
225
|
}
|
|
198
226
|
|
|
199
227
|
.iconButton:disabled {
|
|
@@ -238,3 +266,39 @@
|
|
|
238
266
|
font-size: 13px;
|
|
239
267
|
padding: 0 16px;
|
|
240
268
|
}
|
|
269
|
+
|
|
270
|
+
.thinkingDots {
|
|
271
|
+
display: flex;
|
|
272
|
+
gap: 4px;
|
|
273
|
+
align-items: center;
|
|
274
|
+
height: 20px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.thinkingDots span {
|
|
278
|
+
width: 6px;
|
|
279
|
+
height: 6px;
|
|
280
|
+
border-radius: 50%;
|
|
281
|
+
background: var(--mantine-color-dimmed);
|
|
282
|
+
animation: thinkingDot 1.4s ease-in-out infinite;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.thinkingDots span:nth-child(2) {
|
|
286
|
+
animation-delay: 0.2s;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.thinkingDots span:nth-child(3) {
|
|
290
|
+
animation-delay: 0.4s;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@keyframes thinkingDot {
|
|
294
|
+
0%,
|
|
295
|
+
80%,
|
|
296
|
+
100% {
|
|
297
|
+
opacity: 0.3;
|
|
298
|
+
transform: scale(0.8);
|
|
299
|
+
}
|
|
300
|
+
40% {
|
|
301
|
+
opacity: 1;
|
|
302
|
+
transform: scale(1);
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
export interface WidgetConfig {
|
|
2
2
|
apiBaseUrl: string;
|
|
3
3
|
apiKey?: string;
|
|
4
|
-
|
|
4
|
+
/** LiveKit dispatched agent name (default: "custom-voice-agent"). */
|
|
5
|
+
agentName?: string;
|
|
6
|
+
metadata?: Record<string, string | string[]>;
|
|
5
7
|
branding?: WidgetBranding;
|
|
6
8
|
position?: "bottom-right" | "bottom-left";
|
|
7
9
|
/** Enable voice call (default: true). When false, widget is chat-only. */
|
|
8
10
|
enableVoice?: boolean;
|
|
9
|
-
/** Participant identity sent to LiveKit */
|
|
10
|
-
userIdentity?: string;
|
|
11
|
-
/** Participant display name sent to LiveKit */
|
|
12
|
-
userName?: string;
|
|
13
11
|
/** Extra options passed to fetch (e.g. { credentials: "include" }) */
|
|
14
12
|
fetchOptions?: RequestInit;
|
|
13
|
+
/** Render widget in dark mode (default: false) */
|
|
14
|
+
darkMode?: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface WidgetBranding {
|
|
@@ -1,56 +1,103 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from "react";
|
|
2
1
|
import type { Room } from "livekit-client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
|
|
4
4
|
|
|
5
|
+
const csrfStorageKey = "polymorph.csrf";
|
|
6
|
+
|
|
5
7
|
interface RoomConnection {
|
|
6
8
|
token: string;
|
|
7
9
|
livekitUrl: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
function
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
async function ensureCsrf(apiBaseUrl: string, fetchOptions?: RequestInit) {
|
|
13
|
+
if (sessionStorage.getItem(csrfStorageKey)) return;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${apiBaseUrl}/auth/csrf`, {
|
|
17
|
+
credentials: "include",
|
|
18
|
+
...(fetchOptions ?? {}),
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`CSRF request failed (${res.status})`);
|
|
22
|
+
}
|
|
23
|
+
const data = (await res.json()) as { csrf_token?: string };
|
|
24
|
+
if (data.csrf_token) {
|
|
25
|
+
sessionStorage.setItem(csrfStorageKey, data.csrf_token);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
throw new Error("CSRF token missing in response");
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const message =
|
|
31
|
+
err instanceof Error ? err.message : "Failed to fetch CSRF token";
|
|
32
|
+
throw new Error(message);
|
|
33
|
+
}
|
|
18
34
|
}
|
|
19
35
|
|
|
20
36
|
export function usePolymorphSession(config: WidgetConfig) {
|
|
37
|
+
const isChatOnlyAgent = (config.agentName || "")
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.includes("chat-agent");
|
|
40
|
+
const defaultVoiceEnabled = config.enableVoice !== false && !isChatOnlyAgent;
|
|
21
41
|
const [status, setStatus] = useState<SessionStatus>("idle");
|
|
22
|
-
const [roomConnection, setRoomConnection] = useState<RoomConnection | null>(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
29
|
-
const [isVoiceEnabled, setIsVoiceEnabled] = useState(config.enableVoice !== false);
|
|
30
|
-
const [isMicActive, setIsMicActive] = useState(config.enableVoice !== false);
|
|
42
|
+
const [roomConnection, setRoomConnection] = useState<RoomConnection | null>(
|
|
43
|
+
null,
|
|
44
|
+
);
|
|
45
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
46
|
+
const [isVoiceEnabled, setIsVoiceEnabled] = useState(defaultVoiceEnabled);
|
|
47
|
+
const [isMicActive, setIsMicActive] = useState(defaultVoiceEnabled);
|
|
31
48
|
const [error, setError] = useState<string | null>(null);
|
|
32
49
|
const roomRef = useRef<Room | null>(null);
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}, []);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setIsVoiceEnabled(defaultVoiceEnabled);
|
|
53
|
+
setIsMicActive(defaultVoiceEnabled);
|
|
54
|
+
if (roomRef.current) {
|
|
55
|
+
void roomRef.current.localParticipant.setMicrophoneEnabled(
|
|
56
|
+
defaultVoiceEnabled,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}, [defaultVoiceEnabled]);
|
|
60
|
+
|
|
61
|
+
const addMessage = useCallback(
|
|
62
|
+
(role: "user" | "agent", text: string, source: "chat" | "voice") => {
|
|
63
|
+
setMessages((prev) => [
|
|
64
|
+
...prev,
|
|
65
|
+
{
|
|
66
|
+
id: crypto.randomUUID(),
|
|
67
|
+
role,
|
|
68
|
+
text,
|
|
69
|
+
source,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
},
|
|
74
|
+
[],
|
|
75
|
+
);
|
|
43
76
|
|
|
44
77
|
const connect = useCallback(async () => {
|
|
45
78
|
if (status === "connecting" || status === "connected") return;
|
|
46
79
|
setStatus("connecting");
|
|
47
80
|
setError(null);
|
|
48
81
|
try {
|
|
49
|
-
const { headers: extraHeaders, ...restFetchOptions } =
|
|
82
|
+
const { headers: extraHeaders, ...restFetchOptions } =
|
|
83
|
+
config.fetchOptions ?? {};
|
|
50
84
|
const authHeaders: Record<string, string> = {};
|
|
51
85
|
if (config.apiKey) {
|
|
52
|
-
authHeaders
|
|
86
|
+
authHeaders.Authorization = `Bearer ${config.apiKey}`;
|
|
87
|
+
} else if (restFetchOptions.credentials === "include") {
|
|
88
|
+
await ensureCsrf(config.apiBaseUrl, restFetchOptions);
|
|
89
|
+
const csrf = sessionStorage.getItem(csrfStorageKey);
|
|
90
|
+
if (csrf) authHeaders["x-csrf-token"] = csrf;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Persist a stable external_user_id per browser for session stitching.
|
|
94
|
+
const storageKey = "polymorph_widget_session";
|
|
95
|
+
let externalUserId = localStorage.getItem(storageKey) ?? undefined;
|
|
96
|
+
if (!externalUserId) {
|
|
97
|
+
externalUserId = `widget-session-${crypto.randomUUID().slice(0, 12)}`;
|
|
98
|
+
localStorage.setItem(storageKey, externalUserId);
|
|
53
99
|
}
|
|
100
|
+
|
|
54
101
|
const response = await fetch(`${config.apiBaseUrl}/voice-rooms/start`, {
|
|
55
102
|
method: "POST",
|
|
56
103
|
headers: {
|
|
@@ -61,15 +108,22 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
61
108
|
: extraHeaders),
|
|
62
109
|
},
|
|
63
110
|
body: JSON.stringify({
|
|
64
|
-
agent_name: "custom-voice-agent",
|
|
65
|
-
metadata:
|
|
66
|
-
|
|
67
|
-
|
|
111
|
+
agent_name: config.agentName || "custom-voice-agent",
|
|
112
|
+
metadata: {
|
|
113
|
+
...config.metadata,
|
|
114
|
+
...(config.branding?.greeting && {
|
|
115
|
+
greeting: config.branding.greeting,
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
external_user_id: externalUserId,
|
|
68
119
|
}),
|
|
69
120
|
...restFetchOptions,
|
|
70
121
|
});
|
|
122
|
+
if (response.status === 403) {
|
|
123
|
+
sessionStorage.removeItem(csrfStorageKey);
|
|
124
|
+
}
|
|
71
125
|
if (!response.ok) {
|
|
72
|
-
throw new Error(await response.text() || "Failed to start session");
|
|
126
|
+
throw new Error((await response.text()) || "Failed to start session");
|
|
73
127
|
}
|
|
74
128
|
const data = await response.json();
|
|
75
129
|
setRoomConnection({ token: data.token, livekitUrl: data.livekit_url });
|
|
@@ -86,13 +140,21 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
86
140
|
setStatus("idle");
|
|
87
141
|
}, []);
|
|
88
142
|
|
|
89
|
-
const sendMessage = useCallback(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
const sendMessage = useCallback(
|
|
144
|
+
(text: string) => {
|
|
145
|
+
const room = roomRef.current;
|
|
146
|
+
if (!room || !text.trim()) return;
|
|
147
|
+
addMessage("user", text.trim(), "chat");
|
|
148
|
+
const payload = new TextEncoder().encode(
|
|
149
|
+
JSON.stringify({ text: text.trim() }),
|
|
150
|
+
);
|
|
151
|
+
room.localParticipant.publishData(payload, {
|
|
152
|
+
reliable: true,
|
|
153
|
+
topic: "chat_message",
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
[addMessage],
|
|
157
|
+
);
|
|
96
158
|
|
|
97
159
|
const toggleMic = useCallback(async () => {
|
|
98
160
|
const room = roomRef.current;
|
|
@@ -109,6 +171,13 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
109
171
|
await room.localParticipant.setMicrophoneEnabled(newState);
|
|
110
172
|
setIsVoiceEnabled(newState);
|
|
111
173
|
setIsMicActive(newState);
|
|
174
|
+
const payload = new TextEncoder().encode(
|
|
175
|
+
JSON.stringify({ voice_enabled: newState }),
|
|
176
|
+
);
|
|
177
|
+
room.localParticipant.publishData(payload, {
|
|
178
|
+
reliable: true,
|
|
179
|
+
topic: "voice_mode",
|
|
180
|
+
});
|
|
112
181
|
}, [isVoiceEnabled]);
|
|
113
182
|
|
|
114
183
|
const setRoom = useCallback((room: Room | null) => {
|