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.
- package/dist/index.css +1 -1
- package/dist/index.d.ts +46 -22
- package/dist/index.js +6276 -5929
- package/package.json +1 -1
- package/src/ChatThread.tsx +15 -2
- package/src/IdentityForm.tsx +135 -0
- package/src/PolymorphWidget.tsx +44 -18
- package/src/RoomHandler.tsx +22 -2
- package/src/VoiceOverlay.tsx +60 -11
- package/src/WidgetPanel.tsx +86 -60
- 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 +440 -41
- package/src/types.ts +28 -18
- package/src/usePolymorphSession.ts +338 -78
|
@@ -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,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
|
|
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
|
-
const
|
|
46
|
-
const [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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(
|
|
103
|
-
const csrf = sessionStorage.getItem(
|
|
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
|
|
109
|
-
let externalUserId = localStorage.getItem(
|
|
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(
|
|
296
|
+
localStorage.setItem(sessionKey, externalUserId);
|
|
113
297
|
}
|
|
114
298
|
|
|
115
|
-
|
|
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:
|
|
127
|
-
metadata: config.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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(
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
}
|