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.
@@ -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: transform 150ms ease, box-shadow 150ms ease;
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: 0 8px 30px rgba(0, 0, 0, 0.12);
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: white;
49
- border: 1px solid #e5e5e5;
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 #f0f0f0;
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: #f5f5f5;
83
- color: #171717;
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: #737373;
104
- border-top: 1px solid #f0f0f0;
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) { height: 6px; animation-delay: 0s; }
122
- .voiceBar:nth-child(2) { height: 12px; animation-delay: 0.2s; }
123
- .voiceBar:nth-child(3) { height: 8px; animation-delay: 0.4s; }
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%, 100% { transform: scaleY(1); }
127
- 50% { transform: scaleY(0.4); }
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 #e5e5e5;
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: #f5f5f5;
141
- color: #737373;
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: #e5e5e5;
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 #f0f0f0;
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 #e5e5e5;
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: #a3a3a3;
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: #737373;
192
- transition: background 150ms ease, color 150ms ease;
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: #f5f5f5;
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
- metadata?: Record<string, string>;
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 makeGreeting(text: string): ChatMessage {
11
- return {
12
- id: "greeting",
13
- role: "agent",
14
- text,
15
- source: "chat",
16
- timestamp: Date.now(),
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>(null);
23
- const [messages, setMessages] = useState<ChatMessage[]>(() => {
24
- if (config.branding?.greeting) {
25
- return [makeGreeting(config.branding.greeting)];
26
- }
27
- return [];
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
- const addMessage = useCallback((role: "user" | "agent", text: string, source: "chat" | "voice") => {
35
- setMessages(prev => [...prev, {
36
- id: crypto.randomUUID(),
37
- role,
38
- text,
39
- source,
40
- timestamp: Date.now(),
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 } = config.fetchOptions ?? {};
82
+ const { headers: extraHeaders, ...restFetchOptions } =
83
+ config.fetchOptions ?? {};
50
84
  const authHeaders: Record<string, string> = {};
51
85
  if (config.apiKey) {
52
- authHeaders["Authorization"] = `Bearer ${config.apiKey}`;
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: config.metadata,
66
- user_identity: config.userIdentity,
67
- user_name: config.userName,
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((text: string) => {
90
- const room = roomRef.current;
91
- if (!room || !text.trim()) return;
92
- addMessage("user", text.trim(), "chat");
93
- const payload = new TextEncoder().encode(JSON.stringify({ text: text.trim() }));
94
- room.localParticipant.publishData(payload, { reliable: true, topic: "chat_message" });
95
- }, [addMessage]);
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) => {