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.
@@ -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,47 +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
- const greeting = config.branding?.greeting;
46
- const [messages, setMessages] = useState<ChatMessage[]>(() =>
47
- greeting
48
- ? [
49
- {
50
- id: "greeting",
51
- role: "agent" as const,
52
- text: greeting,
53
- source: "chat" as const,
54
- timestamp: Date.now(),
55
- },
56
- ]
57
- : [],
58
- );
59
- const [isVoiceEnabled, setIsVoiceEnabled] = useState(defaultVoiceEnabled);
60
- const [isMicActive, setIsMicActive] = useState(defaultVoiceEnabled);
96
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
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);
61
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
+ );
62
109
  const roomRef = useRef<Room | null>(null);
63
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;
64
116
 
117
+ // Fetch widget config from backend on mount or when configId changes
65
118
  useEffect(() => {
66
- setIsVoiceEnabled(defaultVoiceEnabled);
67
- setIsMicActive(defaultVoiceEnabled);
68
- if (roomRef.current) {
69
- void roomRef.current.localParticipant.setMicrophoneEnabled(
70
- defaultVoiceEnabled,
71
- );
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
+ }
72
187
  }
73
- }, [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
+ );
74
233
 
75
234
  const addMessage = useCallback(
76
- (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
+ }
77
246
  setMessages((prev) => [
78
247
  ...prev,
79
248
  {
@@ -82,12 +251,25 @@ export function usePolymorphSession(config: WidgetConfig) {
82
251
  text,
83
252
  source,
84
253
  timestamp: Date.now(),
254
+ senderName,
255
+ senderType,
85
256
  },
86
257
  ]);
87
258
  },
88
259
  [],
89
260
  );
90
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
+
91
273
  const connect = useCallback(async () => {
92
274
  if (status === "connecting" || status === "connected") return;
93
275
  setStatus("connecting");
@@ -99,20 +281,25 @@ export function usePolymorphSession(config: WidgetConfig) {
99
281
  if (config.apiKey) {
100
282
  authHeaders.Authorization = `Bearer ${config.apiKey}`;
101
283
  } else if (restFetchOptions.credentials === "include") {
102
- await ensureCsrf(config.apiBaseUrl, restFetchOptions);
103
- const csrf = sessionStorage.getItem(csrfStorageKey);
284
+ await ensureCsrf(apiBaseUrl, scope, restFetchOptions);
285
+ const csrf = sessionStorage.getItem(
286
+ storageKey("polymorph.csrf", scope),
287
+ );
104
288
  if (csrf) authHeaders["x-csrf-token"] = csrf;
105
289
  }
106
290
 
107
291
  // Persist a stable external_user_id per browser for session stitching.
108
- const storageKey = "polymorph_widget_session";
109
- let externalUserId = localStorage.getItem(storageKey) ?? undefined;
292
+ const sessionKey = storageKey("polymorph_widget_session", scope);
293
+ let externalUserId = localStorage.getItem(sessionKey) ?? undefined;
110
294
  if (!externalUserId) {
111
295
  externalUserId = `widget-session-${crypto.randomUUID().slice(0, 12)}`;
112
- localStorage.setItem(storageKey, externalUserId);
296
+ localStorage.setItem(sessionKey, externalUserId);
113
297
  }
114
298
 
115
- 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`, {
116
303
  method: "POST",
117
304
  headers: {
118
305
  "Content-Type": "application/json",
@@ -123,42 +310,44 @@ export function usePolymorphSession(config: WidgetConfig) {
123
310
  },
124
311
  body: JSON.stringify({
125
312
  agent_name: config.agentName || "custom-voice-agent",
126
- config_id: config.configId ?? undefined,
127
- metadata: config.configId
128
- ? undefined
129
- : {
130
- ...config.metadata,
131
- ...(config.branding?.greeting && {
132
- greeting: config.branding.greeting,
133
- }),
134
- ...(config.user?.name && { user_name: config.user.name }),
135
- ...(config.user?.email && { user_email: config.user.email }),
136
- ...(config.user?.phone && { user_phone: config.user.phone }),
137
- },
138
- user_name: config.user?.name,
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,
139
318
  external_user_id: externalUserId,
140
319
  }),
141
320
  ...restFetchOptions,
142
321
  });
143
322
  if (response.status === 403) {
144
- sessionStorage.removeItem(csrfStorageKey);
323
+ sessionStorage.removeItem(storageKey("polymorph.csrf", scope));
145
324
  }
146
325
  if (!response.ok) {
147
326
  throw new Error((await response.text()) || "Failed to start session");
148
327
  }
149
328
  const data = await response.json();
150
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
+ }
151
336
  } catch (err) {
152
337
  setError(err instanceof Error ? err.message : "Something went wrong");
153
338
  setStatus("error");
154
339
  }
155
- }, [config, status]);
340
+ }, [config, status, resolvedUser, resolvedConfig, apiBaseUrl, scope]);
156
341
 
157
342
  const disconnect = useCallback(() => {
158
343
  roomRef.current?.disconnect();
159
344
  roomRef.current = null;
160
345
  setRoomConnection(null);
161
346
  setStatus("idle");
347
+ setIsVoiceEnabled(false);
348
+ setIsMicActive(false);
349
+ setIsScreenSharing(false);
350
+ setHasObserver(false);
162
351
  }, []);
163
352
 
164
353
  const sendMessage = useCallback(
@@ -187,39 +376,99 @@ export function usePolymorphSession(config: WidgetConfig) {
187
376
  const toggleMic = useCallback(async () => {
188
377
  const room = roomRef.current;
189
378
  if (!room) return;
190
- const newState = !isMicActive;
191
- await room.localParticipant.setMicrophoneEnabled(newState);
192
- setIsMicActive(newState);
193
- }, [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
+ }, []);
194
387
 
195
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 () => {
196
415
  const room = roomRef.current;
197
416
  if (!room) return;
198
- const newState = !isVoiceEnabled;
199
- await room.localParticipant.setMicrophoneEnabled(newState);
200
- setIsVoiceEnabled(newState);
201
- setIsMicActive(newState);
202
- const payload = new TextEncoder().encode(
203
- JSON.stringify({ voice_enabled: newState }),
204
- );
205
- room.localParticipant.publishData(payload, {
206
- reliable: true,
207
- topic: "voice_mode",
208
- });
209
- }, [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
+ }, []);
210
425
 
211
426
  const setRoom = useCallback((room: Room | null) => {
212
427
  roomRef.current = room;
213
428
  if (room) {
214
429
  setStatus("connected");
215
- // Flush any messages queued before the room was ready
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.
216
445
  const pending = pendingMessagesRef.current.splice(0);
217
- for (const text of pending) {
218
- const payload = new TextEncoder().encode(JSON.stringify({ text }));
219
- room.localParticipant.publishData(payload, {
220
- reliable: true,
221
- topic: "chat_message",
222
- });
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
+ }
223
472
  }
224
473
  }
225
474
  }, []);
@@ -230,13 +479,24 @@ export function usePolymorphSession(config: WidgetConfig) {
230
479
  messages,
231
480
  isVoiceEnabled,
232
481
  isMicActive,
482
+ isScreenSharing,
483
+ hasObserver,
484
+ hasUnread,
485
+ wiggleKey,
233
486
  error,
487
+ needsIdentityForm,
488
+ identityCollection,
489
+ resolvedConfig,
234
490
  connect,
235
491
  disconnect,
236
492
  addMessage,
237
493
  sendMessage,
238
494
  toggleMic,
239
495
  toggleVoice,
496
+ toggleScreenShare,
240
497
  setRoom,
498
+ setUser,
499
+ clearUnread,
500
+ setPanelOpen,
241
501
  };
242
502
  }