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.
- package/dist/index.css +1 -1
- package/dist/index.d.ts +56 -19
- package/dist/index.js +6338 -5937
- package/package.json +1 -1
- package/src/ChatThread.tsx +42 -9
- package/src/IdentityForm.tsx +135 -0
- package/src/PolymorphWidget.tsx +68 -32
- package/src/RoomHandler.tsx +22 -2
- package/src/VoiceOverlay.tsx +60 -11
- package/src/WidgetPanel.tsx +103 -74
- 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 +203 -67
- package/src/types.ts +39 -16
- package/src/usePolymorphSession.ts +360 -61
|
@@ -1,16 +1,54 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ParticipantKind, type Room, RoomEvent } from "livekit-client";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ChatMessage,
|
|
5
|
+
IdentityCollection,
|
|
6
|
+
ResolvedWidgetConfig,
|
|
7
|
+
SessionStatus,
|
|
8
|
+
WidgetConfig,
|
|
9
|
+
WidgetUser,
|
|
10
|
+
} from "./types";
|
|
4
11
|
|
|
5
|
-
|
|
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(
|
|
13
|
-
|
|
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(
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
47
|
-
const [isMicActive, setIsMicActive] = useState(
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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(
|
|
89
|
-
const csrf = sessionStorage.getItem(
|
|
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
|
|
95
|
-
let externalUserId = localStorage.getItem(
|
|
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(
|
|
296
|
+
localStorage.setItem(sessionKey, externalUserId);
|
|
99
297
|
}
|
|
100
298
|
|
|
101
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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 (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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)
|
|
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
|
}
|