polymorph-sdk 0.2.2 → 0.2.4

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.
@@ -1,16 +1,54 @@
1
- import type { Room } from "livekit-client";
1
+ import { ParticipantKind, type Room, RoomEvent } from "livekit-client";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
3
+ import type {
4
+ ChatMessage,
5
+ IdentityCollection,
6
+ ResolvedWidgetConfig,
7
+ SessionStatus,
8
+ WidgetConfig,
9
+ WidgetUser,
10
+ } from "./types";
4
11
 
5
- const csrfStorageKey = "polymorph.csrf";
12
+ function resolveApiBaseUrl(configured?: string): string {
13
+ if (configured) return configured;
14
+ if (
15
+ typeof window !== "undefined" &&
16
+ (window.location.hostname === "localhost" ||
17
+ window.location.hostname === "127.0.0.1")
18
+ ) {
19
+ return "http://localhost:8080";
20
+ }
21
+ return "https://api.usepolymorph.com";
22
+ }
23
+
24
+ function storageKey(base: string, scope?: string): string {
25
+ return scope ? `${base}:${scope}` : base;
26
+ }
27
+
28
+ function loadStoredIdentity(scope?: string): WidgetUser | undefined {
29
+ try {
30
+ const stored = localStorage.getItem(
31
+ storageKey("polymorph_user_identity", scope),
32
+ );
33
+ if (stored) return JSON.parse(stored) as WidgetUser;
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ return undefined;
38
+ }
6
39
 
7
40
  interface RoomConnection {
8
41
  token: string;
9
42
  livekitUrl: string;
10
43
  }
11
44
 
12
- async function ensureCsrf(apiBaseUrl: string, fetchOptions?: RequestInit) {
13
- if (sessionStorage.getItem(csrfStorageKey)) return;
45
+ async function ensureCsrf(
46
+ apiBaseUrl: string,
47
+ scope: string | undefined,
48
+ fetchOptions?: RequestInit,
49
+ ) {
50
+ const key = storageKey("polymorph.csrf", scope);
51
+ if (sessionStorage.getItem(key)) return;
14
52
 
15
53
  try {
16
54
  const res = await fetch(`${apiBaseUrl}/auth/csrf`, {
@@ -22,7 +60,7 @@ async function ensureCsrf(apiBaseUrl: string, fetchOptions?: RequestInit) {
22
60
  }
23
61
  const data = (await res.json()) as { csrf_token?: string };
24
62
  if (data.csrf_token) {
25
- sessionStorage.setItem(csrfStorageKey, data.csrf_token);
63
+ sessionStorage.setItem(key, data.csrf_token);
26
64
  return;
27
65
  }
28
66
  throw new Error("CSRF token missing in response");
@@ -33,33 +71,178 @@ async function ensureCsrf(apiBaseUrl: string, fetchOptions?: RequestInit) {
33
71
  }
34
72
  }
35
73
 
74
+ function buildAuthHeaders(
75
+ config: WidgetConfig,
76
+ scope: string | undefined,
77
+ ): Record<string, string> {
78
+ const headers: Record<string, string> = {};
79
+ if (config.apiKey) {
80
+ headers.Authorization = `Bearer ${config.apiKey}`;
81
+ } else if (config.fetchOptions?.credentials === "include") {
82
+ const csrf = sessionStorage.getItem(storageKey("polymorph.csrf", scope));
83
+ if (csrf) headers["x-csrf-token"] = csrf;
84
+ }
85
+ return headers;
86
+ }
87
+
36
88
  export function usePolymorphSession(config: WidgetConfig) {
37
- const isChatOnlyAgent = (config.agentName || "")
38
- .toLowerCase()
39
- .includes("chat-agent");
40
- const defaultVoiceEnabled = config.enableVoice !== false && !isChatOnlyAgent;
89
+ const apiBaseUrl = resolveApiBaseUrl(config.apiBaseUrl);
90
+ // Scope storage keys so multiple widget instances don't collide.
91
+ const scope = config.configId || config.apiKey;
41
92
  const [status, setStatus] = useState<SessionStatus>("idle");
42
93
  const [roomConnection, setRoomConnection] = useState<RoomConnection | null>(
43
94
  null,
44
95
  );
45
96
  const [messages, setMessages] = useState<ChatMessage[]>([]);
46
- const [isVoiceEnabled, setIsVoiceEnabled] = useState(defaultVoiceEnabled);
47
- const [isMicActive, setIsMicActive] = useState(defaultVoiceEnabled);
97
+ const [isVoiceEnabled, setIsVoiceEnabled] = useState(false);
98
+ const [isMicActive, setIsMicActive] = useState(false);
99
+ const [isScreenSharing, setIsScreenSharing] = useState(false);
100
+ const [hasObserver, setHasObserver] = useState(false);
101
+ const [hasUnread, setHasUnread] = useState(false);
102
+ const [wiggleKey, setWiggleKey] = useState(0);
48
103
  const [error, setError] = useState<string | null>(null);
104
+ const [resolvedConfig, setResolvedConfig] =
105
+ useState<ResolvedWidgetConfig | null>(null);
106
+ const [resolvedUser, setResolvedUser] = useState<WidgetUser | undefined>(
107
+ config.user ?? loadStoredIdentity(scope),
108
+ );
49
109
  const roomRef = useRef<Room | null>(null);
110
+ const pendingMessagesRef = useRef<string[]>([]);
111
+ const isPanelOpenRef = useRef<boolean>(false);
112
+ // Stable ref for fetchOptions to avoid infinite useEffect loops
113
+ // (inline objects like { credentials: "include" } create new refs each render)
114
+ const fetchOptionsRef = useRef(config.fetchOptions);
115
+ fetchOptionsRef.current = config.fetchOptions;
50
116
 
117
+ // Fetch widget config from backend on mount or when configId changes
51
118
  useEffect(() => {
52
- setIsVoiceEnabled(defaultVoiceEnabled);
53
- setIsMicActive(defaultVoiceEnabled);
54
- if (roomRef.current) {
55
- void roomRef.current.localParticipant.setMicrophoneEnabled(
56
- defaultVoiceEnabled,
57
- );
119
+ let cancelled = false;
120
+
121
+ async function fetchConfig() {
122
+ try {
123
+ const fetchOpts = fetchOptionsRef.current;
124
+ // Ensure CSRF token for cookie-based auth
125
+ if (!config.apiKey && fetchOpts?.credentials === "include") {
126
+ await ensureCsrf(apiBaseUrl, scope, fetchOpts);
127
+ }
128
+
129
+ const params = config.configId ? `?config_id=${config.configId}` : "";
130
+ const res = await fetch(
131
+ `${apiBaseUrl}/widget-configs/resolve${params}`,
132
+ {
133
+ headers: {
134
+ ...buildAuthHeaders(config, scope),
135
+ ...(fetchOpts?.headers instanceof Headers
136
+ ? Object.fromEntries(fetchOpts.headers.entries())
137
+ : (fetchOpts?.headers as Record<string, string> | undefined)),
138
+ },
139
+ credentials: fetchOpts?.credentials,
140
+ },
141
+ );
142
+ if (!res.ok) return;
143
+ const data = await res.json();
144
+ if (cancelled) return;
145
+
146
+ const cfg: ResolvedWidgetConfig = {
147
+ id: data.id,
148
+ title: data.title,
149
+ subtitle: data.subtitle,
150
+ primaryColor: data.primary_color,
151
+ position: data.position,
152
+ darkMode: data.dark_mode,
153
+ enableVoice: data.enable_voice,
154
+ greeting: data.greeting,
155
+ collectEmail: data.collect_email,
156
+ collectPhone: data.collect_phone,
157
+ };
158
+ setResolvedConfig(cfg);
159
+
160
+ // Show greeting immediately (before agent connects).
161
+ // Use config greeting, or personalized fallback matching agent logic.
162
+ const userName = resolvedUser?.name;
163
+ const greetingText =
164
+ cfg.greeting ||
165
+ (userName
166
+ ? `Hey ${userName}! How can I help ya today?`
167
+ : "Hey! How can I help ya today?");
168
+ setMessages((prev) => {
169
+ if (prev.length > 0) return prev;
170
+ return [
171
+ {
172
+ id: "greeting",
173
+ role: "agent" as const,
174
+ text: greetingText,
175
+ source: "chat" as const,
176
+ timestamp: Date.now(),
177
+ },
178
+ ];
179
+ });
180
+
181
+ // Start in chat-only mode; user can enable voice via the overlay toggle
182
+ setIsVoiceEnabled(false);
183
+ setIsMicActive(false);
184
+ } catch {
185
+ // Config fetch failed - widget will show defaults
186
+ }
58
187
  }
59
- }, [defaultVoiceEnabled]);
188
+
189
+ void fetchConfig();
190
+ return () => {
191
+ cancelled = true;
192
+ };
193
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
+ }, [
195
+ apiBaseUrl,
196
+ config.apiKey,
197
+ config.configId,
198
+ config.agentName,
199
+ scope,
200
+ config,
201
+ resolvedUser?.name,
202
+ ]);
203
+
204
+ // Derive identity collection from resolved config
205
+ const identityCollection: IdentityCollection | null = resolvedConfig
206
+ ? {
207
+ collectEmail: resolvedConfig.collectEmail,
208
+ collectPhone: resolvedConfig.collectPhone,
209
+ }
210
+ : null;
211
+
212
+ // Determine if identity form is needed
213
+ const needsIdentityForm =
214
+ identityCollection !== null &&
215
+ (identityCollection.collectEmail !== "hidden" ||
216
+ identityCollection.collectPhone !== "hidden") &&
217
+ !resolvedUser?.name;
218
+
219
+ const setUser = useCallback(
220
+ (user: WidgetUser) => {
221
+ setResolvedUser(user);
222
+ try {
223
+ localStorage.setItem(
224
+ storageKey("polymorph_user_identity", scope),
225
+ JSON.stringify(user),
226
+ );
227
+ } catch {
228
+ /* quota exceeded, etc */
229
+ }
230
+ },
231
+ [scope],
232
+ );
60
233
 
61
234
  const addMessage = useCallback(
62
- (role: "user" | "agent", text: string, source: "chat" | "voice") => {
235
+ (
236
+ role: "user" | "agent",
237
+ text: string,
238
+ source: "chat" | "voice",
239
+ senderName?: string,
240
+ senderType?: "human",
241
+ ) => {
242
+ if (role === "agent" && !isPanelOpenRef.current) {
243
+ setHasUnread(true);
244
+ setWiggleKey((k) => k + 1);
245
+ }
63
246
  setMessages((prev) => [
64
247
  ...prev,
65
248
  {
@@ -68,12 +251,25 @@ export function usePolymorphSession(config: WidgetConfig) {
68
251
  text,
69
252
  source,
70
253
  timestamp: Date.now(),
254
+ senderName,
255
+ senderType,
71
256
  },
72
257
  ]);
73
258
  },
74
259
  [],
75
260
  );
76
261
 
262
+ const clearUnread = useCallback(() => {
263
+ setHasUnread(false);
264
+ }, []);
265
+
266
+ const setPanelOpen = useCallback((open: boolean) => {
267
+ isPanelOpenRef.current = open;
268
+ if (open) {
269
+ setHasUnread(false);
270
+ }
271
+ }, []);
272
+
77
273
  const connect = useCallback(async () => {
78
274
  if (status === "connecting" || status === "connected") return;
79
275
  setStatus("connecting");
@@ -85,20 +281,25 @@ export function usePolymorphSession(config: WidgetConfig) {
85
281
  if (config.apiKey) {
86
282
  authHeaders.Authorization = `Bearer ${config.apiKey}`;
87
283
  } else if (restFetchOptions.credentials === "include") {
88
- await ensureCsrf(config.apiBaseUrl, restFetchOptions);
89
- const csrf = sessionStorage.getItem(csrfStorageKey);
284
+ await ensureCsrf(apiBaseUrl, scope, restFetchOptions);
285
+ const csrf = sessionStorage.getItem(
286
+ storageKey("polymorph.csrf", scope),
287
+ );
90
288
  if (csrf) authHeaders["x-csrf-token"] = csrf;
91
289
  }
92
290
 
93
291
  // Persist a stable external_user_id per browser for session stitching.
94
- const storageKey = "polymorph_widget_session";
95
- let externalUserId = localStorage.getItem(storageKey) ?? undefined;
292
+ const sessionKey = storageKey("polymorph_widget_session", scope);
293
+ let externalUserId = localStorage.getItem(sessionKey) ?? undefined;
96
294
  if (!externalUserId) {
97
295
  externalUserId = `widget-session-${crypto.randomUUID().slice(0, 12)}`;
98
- localStorage.setItem(storageKey, externalUserId);
296
+ localStorage.setItem(sessionKey, externalUserId);
99
297
  }
100
298
 
101
- const response = await fetch(`${config.apiBaseUrl}/voice-rooms/start`, {
299
+ // Use the resolved config ID (from backend) or fall back to prop
300
+ const resolvedConfigId = resolvedConfig?.id ?? config.configId;
301
+
302
+ const response = await fetch(`${apiBaseUrl}/voice-rooms/start`, {
102
303
  method: "POST",
103
304
  headers: {
104
305
  "Content-Type": "application/json",
@@ -109,80 +310,167 @@ export function usePolymorphSession(config: WidgetConfig) {
109
310
  },
110
311
  body: JSON.stringify({
111
312
  agent_name: config.agentName || "custom-voice-agent",
112
- metadata: {
113
- ...config.metadata,
114
- ...(config.branding?.greeting && {
115
- greeting: config.branding.greeting,
116
- }),
117
- },
313
+ config_id: resolvedConfigId ?? undefined,
314
+ metadata: resolvedConfigId ? undefined : config.metadata,
315
+ user_name: resolvedUser?.name,
316
+ user_email: resolvedUser?.email,
317
+ user_phone: resolvedUser?.phone,
118
318
  external_user_id: externalUserId,
119
319
  }),
120
320
  ...restFetchOptions,
121
321
  });
122
322
  if (response.status === 403) {
123
- sessionStorage.removeItem(csrfStorageKey);
323
+ sessionStorage.removeItem(storageKey("polymorph.csrf", scope));
124
324
  }
125
325
  if (!response.ok) {
126
326
  throw new Error((await response.text()) || "Failed to start session");
127
327
  }
128
328
  const data = await response.json();
129
329
  setRoomConnection({ token: data.token, livekitUrl: data.livekit_url });
330
+ if (data.id && data.join_token) {
331
+ config.onSessionStart?.({
332
+ roomId: data.id,
333
+ joinToken: data.join_token,
334
+ });
335
+ }
130
336
  } catch (err) {
131
337
  setError(err instanceof Error ? err.message : "Something went wrong");
132
338
  setStatus("error");
133
339
  }
134
- }, [config, status]);
340
+ }, [config, status, resolvedUser, resolvedConfig, apiBaseUrl, scope]);
135
341
 
136
342
  const disconnect = useCallback(() => {
137
343
  roomRef.current?.disconnect();
138
344
  roomRef.current = null;
139
345
  setRoomConnection(null);
140
346
  setStatus("idle");
347
+ setIsVoiceEnabled(false);
348
+ setIsMicActive(false);
349
+ setIsScreenSharing(false);
350
+ setHasObserver(false);
141
351
  }, []);
142
352
 
143
353
  const sendMessage = useCallback(
144
354
  (text: string) => {
355
+ if (!text.trim()) return;
356
+ const trimmed = text.trim();
357
+ addMessage("user", trimmed, "chat");
358
+
145
359
  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
- });
360
+ if (room) {
361
+ const payload = new TextEncoder().encode(
362
+ JSON.stringify({ text: trimmed }),
363
+ );
364
+ room.localParticipant.publishData(payload, {
365
+ reliable: true,
366
+ topic: "chat_message",
367
+ });
368
+ } else {
369
+ pendingMessagesRef.current.push(trimmed);
370
+ void connect();
371
+ }
155
372
  },
156
- [addMessage],
373
+ [addMessage, connect],
157
374
  );
158
375
 
159
376
  const toggleMic = useCallback(async () => {
160
377
  const room = roomRef.current;
161
378
  if (!room) return;
162
- const newState = !isMicActive;
163
- await room.localParticipant.setMicrophoneEnabled(newState);
164
- setIsMicActive(newState);
165
- }, [isMicActive]);
379
+ try {
380
+ const newState = !room.localParticipant.isMicrophoneEnabled;
381
+ await room.localParticipant.setMicrophoneEnabled(newState);
382
+ setIsMicActive(newState);
383
+ } catch {
384
+ /* mic toggle failed — ignore */
385
+ }
386
+ }, []);
166
387
 
167
388
  const toggleVoice = useCallback(async () => {
389
+ const room = roomRef.current;
390
+ if (!room) {
391
+ // Not connected yet — connect with voice enabled
392
+ setIsVoiceEnabled(true);
393
+ setIsMicActive(true);
394
+ await connect();
395
+ return;
396
+ }
397
+ try {
398
+ const newState = !isVoiceEnabled;
399
+ await room.localParticipant.setMicrophoneEnabled(newState);
400
+ setIsVoiceEnabled(newState);
401
+ setIsMicActive(newState);
402
+ const payload = new TextEncoder().encode(
403
+ JSON.stringify({ voice_enabled: newState }),
404
+ );
405
+ room.localParticipant.publishData(payload, {
406
+ reliable: true,
407
+ topic: "voice_mode",
408
+ });
409
+ } catch {
410
+ /* voice toggle failed — ignore */
411
+ }
412
+ }, [isVoiceEnabled, connect]);
413
+
414
+ const toggleScreenShare = useCallback(async () => {
168
415
  const room = roomRef.current;
169
416
  if (!room) return;
170
- const newState = !isVoiceEnabled;
171
- await room.localParticipant.setMicrophoneEnabled(newState);
172
- setIsVoiceEnabled(newState);
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
- });
181
- }, [isVoiceEnabled]);
417
+ try {
418
+ const newState = !room.localParticipant.isScreenShareEnabled;
419
+ await room.localParticipant.setScreenShareEnabled(newState);
420
+ setIsScreenSharing(newState);
421
+ } catch {
422
+ /* screen share toggle failed — ignore */
423
+ }
424
+ }, []);
182
425
 
183
426
  const setRoom = useCallback((room: Room | null) => {
184
427
  roomRef.current = room;
185
- if (room) setStatus("connected");
428
+ if (room) {
429
+ setStatus("connected");
430
+
431
+ // Track observer (non-agent remote participant) presence
432
+ const checkObserver = () => {
433
+ const hasNonAgent = Array.from(room.remoteParticipants.values()).some(
434
+ (p) => p.kind !== ParticipantKind.AGENT,
435
+ );
436
+ setHasObserver(hasNonAgent);
437
+ };
438
+ checkObserver();
439
+ room.on(RoomEvent.ParticipantConnected, checkObserver);
440
+ room.on(RoomEvent.ParticipantDisconnected, checkObserver);
441
+
442
+ // Flush pending messages once the agent participant has joined the room.
443
+ // LiveKit data messages are only delivered to participants currently in
444
+ // the room, so sending before the agent connects means messages are lost.
445
+ const pending = pendingMessagesRef.current.splice(0);
446
+ if (pending.length > 0) {
447
+ let flushed = false;
448
+ const flush = () => {
449
+ if (flushed) return;
450
+ flushed = true;
451
+ for (const text of pending) {
452
+ const payload = new TextEncoder().encode(JSON.stringify({ text }));
453
+ room.localParticipant.publishData(payload, {
454
+ reliable: true,
455
+ topic: "chat_message",
456
+ });
457
+ }
458
+ };
459
+ if (room.remoteParticipants.size > 0) {
460
+ setTimeout(flush, 500);
461
+ } else {
462
+ const onJoin = () => {
463
+ room.off(RoomEvent.ParticipantConnected, onJoin);
464
+ setTimeout(flush, 500);
465
+ };
466
+ room.on(RoomEvent.ParticipantConnected, onJoin);
467
+ setTimeout(() => {
468
+ room.off(RoomEvent.ParticipantConnected, onJoin);
469
+ flush();
470
+ }, 5000);
471
+ }
472
+ }
473
+ }
186
474
  }, []);
187
475
 
188
476
  return {
@@ -191,13 +479,24 @@ export function usePolymorphSession(config: WidgetConfig) {
191
479
  messages,
192
480
  isVoiceEnabled,
193
481
  isMicActive,
482
+ isScreenSharing,
483
+ hasObserver,
484
+ hasUnread,
485
+ wiggleKey,
194
486
  error,
487
+ needsIdentityForm,
488
+ identityCollection,
489
+ resolvedConfig,
195
490
  connect,
196
491
  disconnect,
197
492
  addMessage,
198
493
  sendMessage,
199
494
  toggleMic,
200
495
  toggleVoice,
496
+ toggleScreenShare,
201
497
  setRoom,
498
+ setUser,
499
+ clearUnread,
500
+ setPanelOpen,
202
501
  };
203
502
  }