polymorph-sdk 0.1.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.
- package/README.md +58 -84
- package/dist/index.css +2 -0
- package/dist/index.d.ts +64 -135
- package/dist/index.js +18187 -259
- package/package.json +38 -34
- package/src/ChatThread.tsx +70 -0
- package/src/PolymorphWidget.tsx +103 -0
- package/src/RoomHandler.tsx +99 -0
- package/src/VoiceOverlay.tsx +85 -0
- package/src/WidgetPanel.tsx +228 -0
- package/src/__tests__/exports.test.ts +13 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +8 -0
- package/src/styles.module.css +304 -0
- package/src/theme.ts +53 -0
- package/src/types.ts +36 -0
- package/src/usePolymorphSession.ts +203 -0
- package/dist/index.d.mts +0 -135
- package/dist/index.mjs +0 -233
package/src/index.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
.widgetRoot {
|
|
2
|
+
position: fixed;
|
|
3
|
+
z-index: 9999;
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
align-items: flex-end;
|
|
7
|
+
justify-content: flex-end;
|
|
8
|
+
gap: 12px;
|
|
9
|
+
pointer-events: none;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.bottomRight {
|
|
13
|
+
top: 24px;
|
|
14
|
+
bottom: 24px;
|
|
15
|
+
right: 24px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.bottomLeft {
|
|
19
|
+
top: 24px;
|
|
20
|
+
bottom: 24px;
|
|
21
|
+
left: 24px;
|
|
22
|
+
align-items: flex-start;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.fab {
|
|
26
|
+
width: 56px;
|
|
27
|
+
height: 56px;
|
|
28
|
+
border-radius: 28px;
|
|
29
|
+
border: none;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
35
|
+
transition:
|
|
36
|
+
transform 150ms ease,
|
|
37
|
+
box-shadow 150ms ease;
|
|
38
|
+
color: white;
|
|
39
|
+
pointer-events: auto;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.fab:hover {
|
|
43
|
+
transform: scale(1.05);
|
|
44
|
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.panel {
|
|
48
|
+
width: 380px;
|
|
49
|
+
max-height: 600px;
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
border-radius: 16px;
|
|
53
|
+
box-shadow:
|
|
54
|
+
0 4px 24px rgba(0, 0, 0, 0.08),
|
|
55
|
+
0 12px 48px rgba(0, 0, 0, 0.12);
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
background: var(--mantine-color-body);
|
|
58
|
+
color: var(--mantine-color-text);
|
|
59
|
+
pointer-events: auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.header {
|
|
63
|
+
padding: 16px 16px 12px;
|
|
64
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: flex-start;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.chatThread {
|
|
71
|
+
flex: 1;
|
|
72
|
+
overflow-y: auto;
|
|
73
|
+
padding: 16px;
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 8px;
|
|
77
|
+
min-height: 200px;
|
|
78
|
+
max-height: 400px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.messageBubble {
|
|
82
|
+
max-width: 80%;
|
|
83
|
+
padding: 8px 12px;
|
|
84
|
+
border-radius: 12px;
|
|
85
|
+
font-size: 14px;
|
|
86
|
+
line-height: 1.4;
|
|
87
|
+
word-wrap: break-word;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.agentMessage {
|
|
91
|
+
align-self: flex-start;
|
|
92
|
+
background: var(--mantine-color-gray-light);
|
|
93
|
+
color: var(--mantine-color-text);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.userMessage {
|
|
97
|
+
align-self: flex-end;
|
|
98
|
+
color: white;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.voiceLabel {
|
|
102
|
+
font-size: 10px;
|
|
103
|
+
opacity: 0.6;
|
|
104
|
+
margin-top: 2px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.voiceOverlay {
|
|
108
|
+
padding: 8px 16px;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
gap: 8px;
|
|
112
|
+
font-size: 13px;
|
|
113
|
+
color: var(--mantine-color-dimmed);
|
|
114
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.voiceBars {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 2px;
|
|
121
|
+
height: 16px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.voiceBar {
|
|
125
|
+
width: 3px;
|
|
126
|
+
border-radius: 2px;
|
|
127
|
+
background: #22c55e;
|
|
128
|
+
animation: voiceBar 1.2s ease-in-out infinite;
|
|
129
|
+
}
|
|
130
|
+
|
|
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
|
+
}
|
|
143
|
+
|
|
144
|
+
@keyframes voiceBar {
|
|
145
|
+
0%,
|
|
146
|
+
100% {
|
|
147
|
+
transform: scaleY(1);
|
|
148
|
+
}
|
|
149
|
+
50% {
|
|
150
|
+
transform: scaleY(0.4);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.voiceToggle {
|
|
155
|
+
margin-left: auto;
|
|
156
|
+
width: 28px;
|
|
157
|
+
height: 28px;
|
|
158
|
+
border-radius: 14px;
|
|
159
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
justify-content: center;
|
|
164
|
+
background: var(--mantine-color-gray-light);
|
|
165
|
+
color: var(--mantine-color-dimmed);
|
|
166
|
+
transition: all 150ms ease;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.voiceToggle:hover {
|
|
170
|
+
background: var(--mantine-color-gray-light-hover);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.voiceToggleActive {
|
|
174
|
+
background: light-dark(#dcfce7, #1a3a2a);
|
|
175
|
+
border-color: light-dark(#bbf7d0, #2a5a3a);
|
|
176
|
+
color: light-dark(#16a34a, #4ade80);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.voiceToggleActive:hover {
|
|
180
|
+
background: light-dark(#bbf7d0, #2a5a3a);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.inputBar {
|
|
184
|
+
padding: 12px 16px;
|
|
185
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
186
|
+
display: flex;
|
|
187
|
+
gap: 8px;
|
|
188
|
+
align-items: center;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.inputField {
|
|
192
|
+
flex: 1;
|
|
193
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
194
|
+
border-radius: 8px;
|
|
195
|
+
padding: 8px 12px;
|
|
196
|
+
font-size: 14px;
|
|
197
|
+
outline: none;
|
|
198
|
+
font-family: inherit;
|
|
199
|
+
background: var(--mantine-color-body);
|
|
200
|
+
color: var(--mantine-color-text);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.inputField:focus {
|
|
204
|
+
border-color: var(--mantine-color-dimmed);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.iconButton {
|
|
208
|
+
width: 36px;
|
|
209
|
+
height: 36px;
|
|
210
|
+
border-radius: 8px;
|
|
211
|
+
border: none;
|
|
212
|
+
cursor: pointer;
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
justify-content: center;
|
|
216
|
+
background: transparent;
|
|
217
|
+
color: var(--mantine-color-dimmed);
|
|
218
|
+
transition:
|
|
219
|
+
background 150ms ease,
|
|
220
|
+
color 150ms ease;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.iconButton:hover {
|
|
224
|
+
background: var(--mantine-color-gray-light);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.iconButton:disabled {
|
|
228
|
+
opacity: 0.4;
|
|
229
|
+
cursor: default;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.iconButtonActive {
|
|
233
|
+
color: #22c55e;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.connectButton {
|
|
237
|
+
margin: 16px;
|
|
238
|
+
padding: 10px 20px;
|
|
239
|
+
border-radius: 8px;
|
|
240
|
+
border: none;
|
|
241
|
+
cursor: pointer;
|
|
242
|
+
font-size: 14px;
|
|
243
|
+
font-weight: 500;
|
|
244
|
+
color: white;
|
|
245
|
+
transition: opacity 150ms ease;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.connectButton:hover {
|
|
249
|
+
opacity: 0.9;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.connectButton:disabled {
|
|
253
|
+
opacity: 0.5;
|
|
254
|
+
cursor: default;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.statusBadge {
|
|
258
|
+
font-size: 11px;
|
|
259
|
+
padding: 2px 8px;
|
|
260
|
+
border-radius: 10px;
|
|
261
|
+
font-weight: 500;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.errorText {
|
|
265
|
+
color: #dc2626;
|
|
266
|
+
font-size: 13px;
|
|
267
|
+
padding: 0 16px;
|
|
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/theme.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
|
|
2
|
+
import { createTheme } from "@mantine/core";
|
|
3
|
+
|
|
4
|
+
function hexToMantineTuple(hex: string): MantineColorsTuple {
|
|
5
|
+
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
6
|
+
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
7
|
+
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
8
|
+
|
|
9
|
+
const lighten = (amt: number) =>
|
|
10
|
+
`#${[r, g, b]
|
|
11
|
+
.map((c) =>
|
|
12
|
+
Math.min(255, Math.round(c + (255 - c) * amt))
|
|
13
|
+
.toString(16)
|
|
14
|
+
.padStart(2, "0"),
|
|
15
|
+
)
|
|
16
|
+
.join("")}`;
|
|
17
|
+
|
|
18
|
+
const darken = (amt: number) =>
|
|
19
|
+
`#${[r, g, b]
|
|
20
|
+
.map((c) =>
|
|
21
|
+
Math.max(0, Math.round(c * (1 - amt)))
|
|
22
|
+
.toString(16)
|
|
23
|
+
.padStart(2, "0"),
|
|
24
|
+
)
|
|
25
|
+
.join("")}`;
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
lighten(0.92),
|
|
29
|
+
lighten(0.84),
|
|
30
|
+
lighten(0.72),
|
|
31
|
+
lighten(0.56),
|
|
32
|
+
lighten(0.36),
|
|
33
|
+
hex,
|
|
34
|
+
darken(0.08),
|
|
35
|
+
darken(0.16),
|
|
36
|
+
darken(0.28),
|
|
37
|
+
darken(0.4),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildWidgetTheme(
|
|
42
|
+
primaryColor = "#171717",
|
|
43
|
+
): MantineThemeOverride {
|
|
44
|
+
return createTheme({
|
|
45
|
+
fontFamily:
|
|
46
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
47
|
+
primaryColor: "brand",
|
|
48
|
+
colors: {
|
|
49
|
+
brand: hexToMantineTuple(primaryColor),
|
|
50
|
+
},
|
|
51
|
+
defaultRadius: "md",
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface WidgetConfig {
|
|
2
|
+
apiBaseUrl: string;
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
/** LiveKit dispatched agent name (default: "custom-voice-agent"). */
|
|
5
|
+
agentName?: string;
|
|
6
|
+
metadata?: Record<string, string | string[]>;
|
|
7
|
+
branding?: WidgetBranding;
|
|
8
|
+
position?: "bottom-right" | "bottom-left";
|
|
9
|
+
/** Enable voice call (default: true). When false, widget is chat-only. */
|
|
10
|
+
enableVoice?: boolean;
|
|
11
|
+
/** Extra options passed to fetch (e.g. { credentials: "include" }) */
|
|
12
|
+
fetchOptions?: RequestInit;
|
|
13
|
+
/** Render widget in dark mode (default: false) */
|
|
14
|
+
darkMode?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WidgetBranding {
|
|
18
|
+
/** FAB and accent color (default: "#171717") */
|
|
19
|
+
primaryColor?: string;
|
|
20
|
+
/** Panel header title (default: "Hi there") */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Panel subheader */
|
|
23
|
+
subtitle?: string;
|
|
24
|
+
/** Initial message shown before agent connects */
|
|
25
|
+
greeting?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChatMessage {
|
|
29
|
+
id: string;
|
|
30
|
+
role: "user" | "agent";
|
|
31
|
+
text: string;
|
|
32
|
+
source: "chat" | "voice";
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SessionStatus = "idle" | "connecting" | "connected" | "error";
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Room } from "livekit-client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
const csrfStorageKey = "polymorph.csrf";
|
|
6
|
+
|
|
7
|
+
interface RoomConnection {
|
|
8
|
+
token: string;
|
|
9
|
+
livekitUrl: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
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
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function usePolymorphSession(config: WidgetConfig) {
|
|
37
|
+
const isChatOnlyAgent = (config.agentName || "")
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.includes("chat-agent");
|
|
40
|
+
const defaultVoiceEnabled = config.enableVoice !== false && !isChatOnlyAgent;
|
|
41
|
+
const [status, setStatus] = useState<SessionStatus>("idle");
|
|
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);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const roomRef = useRef<Room | null>(null);
|
|
50
|
+
|
|
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
|
+
);
|
|
76
|
+
|
|
77
|
+
const connect = useCallback(async () => {
|
|
78
|
+
if (status === "connecting" || status === "connected") return;
|
|
79
|
+
setStatus("connecting");
|
|
80
|
+
setError(null);
|
|
81
|
+
try {
|
|
82
|
+
const { headers: extraHeaders, ...restFetchOptions } =
|
|
83
|
+
config.fetchOptions ?? {};
|
|
84
|
+
const authHeaders: Record<string, string> = {};
|
|
85
|
+
if (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);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const response = await fetch(`${config.apiBaseUrl}/voice-rooms/start`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
...authHeaders,
|
|
106
|
+
...(extraHeaders instanceof Headers
|
|
107
|
+
? Object.fromEntries(extraHeaders.entries())
|
|
108
|
+
: extraHeaders),
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({
|
|
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,
|
|
119
|
+
}),
|
|
120
|
+
...restFetchOptions,
|
|
121
|
+
});
|
|
122
|
+
if (response.status === 403) {
|
|
123
|
+
sessionStorage.removeItem(csrfStorageKey);
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new Error((await response.text()) || "Failed to start session");
|
|
127
|
+
}
|
|
128
|
+
const data = await response.json();
|
|
129
|
+
setRoomConnection({ token: data.token, livekitUrl: data.livekit_url });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
132
|
+
setStatus("error");
|
|
133
|
+
}
|
|
134
|
+
}, [config, status]);
|
|
135
|
+
|
|
136
|
+
const disconnect = useCallback(() => {
|
|
137
|
+
roomRef.current?.disconnect();
|
|
138
|
+
roomRef.current = null;
|
|
139
|
+
setRoomConnection(null);
|
|
140
|
+
setStatus("idle");
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
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
|
+
);
|
|
158
|
+
|
|
159
|
+
const toggleMic = useCallback(async () => {
|
|
160
|
+
const room = roomRef.current;
|
|
161
|
+
if (!room) return;
|
|
162
|
+
const newState = !isMicActive;
|
|
163
|
+
await room.localParticipant.setMicrophoneEnabled(newState);
|
|
164
|
+
setIsMicActive(newState);
|
|
165
|
+
}, [isMicActive]);
|
|
166
|
+
|
|
167
|
+
const toggleVoice = useCallback(async () => {
|
|
168
|
+
const room = roomRef.current;
|
|
169
|
+
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]);
|
|
182
|
+
|
|
183
|
+
const setRoom = useCallback((room: Room | null) => {
|
|
184
|
+
roomRef.current = room;
|
|
185
|
+
if (room) setStatus("connected");
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
status,
|
|
190
|
+
roomConnection,
|
|
191
|
+
messages,
|
|
192
|
+
isVoiceEnabled,
|
|
193
|
+
isMicActive,
|
|
194
|
+
error,
|
|
195
|
+
connect,
|
|
196
|
+
disconnect,
|
|
197
|
+
addMessage,
|
|
198
|
+
sendMessage,
|
|
199
|
+
toggleMic,
|
|
200
|
+
toggleVoice,
|
|
201
|
+
setRoom,
|
|
202
|
+
};
|
|
203
|
+
}
|