polymorph-sdk 0.2.0 → 0.2.3

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/src/types.ts CHANGED
@@ -1,17 +1,30 @@
1
1
  export interface WidgetConfig {
2
2
  apiBaseUrl: string;
3
3
  apiKey?: string;
4
- metadata?: Record<string, string>;
4
+ /** Server-side widget config ID. When set, metadata is ignored and config is resolved server-side. */
5
+ configId?: string;
6
+ /** LiveKit dispatched agent name (default: "custom-voice-agent"). */
7
+ agentName?: string;
8
+ metadata?: Record<string, string | string[]>;
9
+ /** Pre-filled user identity. When provided, the agent skips asking for these fields. */
10
+ user?: WidgetUser;
5
11
  branding?: WidgetBranding;
6
12
  position?: "bottom-right" | "bottom-left";
7
13
  /** Enable voice call (default: true). When false, widget is chat-only. */
8
14
  enableVoice?: boolean;
9
- /** Participant identity sent to LiveKit */
10
- userIdentity?: string;
11
- /** Participant display name sent to LiveKit */
12
- userName?: string;
13
15
  /** Extra options passed to fetch (e.g. { credentials: "include" }) */
14
16
  fetchOptions?: RequestInit;
17
+ /** Render widget in dark mode (default: false) */
18
+ darkMode?: boolean;
19
+ }
20
+
21
+ export interface WidgetUser {
22
+ /** Display name (e.g. "Jane Smith") */
23
+ name?: string;
24
+ /** Email address */
25
+ email?: string;
26
+ /** Phone number */
27
+ phone?: string;
15
28
  }
16
29
 
17
30
  export interface WidgetBranding {
@@ -1,56 +1,117 @@
1
- import { useCallback, useRef, useState } from "react";
2
1
  import type { Room } from "livekit-client";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
4
4
 
5
+ const csrfStorageKey = "polymorph.csrf";
6
+
5
7
  interface RoomConnection {
6
8
  token: string;
7
9
  livekitUrl: string;
8
10
  }
9
11
 
10
- function makeGreeting(text: string): ChatMessage {
11
- return {
12
- id: "greeting",
13
- role: "agent",
14
- text,
15
- source: "chat",
16
- timestamp: Date.now(),
17
- };
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
+ }
18
34
  }
19
35
 
20
36
  export function usePolymorphSession(config: WidgetConfig) {
37
+ const isChatOnlyAgent = (config.agentName || "")
38
+ .toLowerCase()
39
+ .includes("chat-agent");
40
+ const defaultVoiceEnabled = config.enableVoice !== false && !isChatOnlyAgent;
21
41
  const [status, setStatus] = useState<SessionStatus>("idle");
22
- const [roomConnection, setRoomConnection] = useState<RoomConnection | null>(null);
23
- const [messages, setMessages] = useState<ChatMessage[]>(() => {
24
- if (config.branding?.greeting) {
25
- return [makeGreeting(config.branding.greeting)];
26
- }
27
- return [];
28
- });
29
- const [isVoiceEnabled, setIsVoiceEnabled] = useState(config.enableVoice !== false);
30
- const [isMicActive, setIsMicActive] = useState(config.enableVoice !== false);
42
+ const [roomConnection, setRoomConnection] = useState<RoomConnection | null>(
43
+ null,
44
+ );
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);
31
61
  const [error, setError] = useState<string | null>(null);
32
62
  const roomRef = useRef<Room | null>(null);
63
+ const pendingMessagesRef = useRef<string[]>([]);
33
64
 
34
- const addMessage = useCallback((role: "user" | "agent", text: string, source: "chat" | "voice") => {
35
- setMessages(prev => [...prev, {
36
- id: crypto.randomUUID(),
37
- role,
38
- text,
39
- source,
40
- timestamp: Date.now(),
41
- }]);
42
- }, []);
65
+ useEffect(() => {
66
+ setIsVoiceEnabled(defaultVoiceEnabled);
67
+ setIsMicActive(defaultVoiceEnabled);
68
+ if (roomRef.current) {
69
+ void roomRef.current.localParticipant.setMicrophoneEnabled(
70
+ defaultVoiceEnabled,
71
+ );
72
+ }
73
+ }, [defaultVoiceEnabled]);
74
+
75
+ const addMessage = useCallback(
76
+ (role: "user" | "agent", text: string, source: "chat" | "voice") => {
77
+ setMessages((prev) => [
78
+ ...prev,
79
+ {
80
+ id: crypto.randomUUID(),
81
+ role,
82
+ text,
83
+ source,
84
+ timestamp: Date.now(),
85
+ },
86
+ ]);
87
+ },
88
+ [],
89
+ );
43
90
 
44
91
  const connect = useCallback(async () => {
45
92
  if (status === "connecting" || status === "connected") return;
46
93
  setStatus("connecting");
47
94
  setError(null);
48
95
  try {
49
- const { headers: extraHeaders, ...restFetchOptions } = config.fetchOptions ?? {};
96
+ const { headers: extraHeaders, ...restFetchOptions } =
97
+ config.fetchOptions ?? {};
50
98
  const authHeaders: Record<string, string> = {};
51
99
  if (config.apiKey) {
52
- authHeaders["Authorization"] = `Bearer ${config.apiKey}`;
100
+ authHeaders.Authorization = `Bearer ${config.apiKey}`;
101
+ } else if (restFetchOptions.credentials === "include") {
102
+ await ensureCsrf(config.apiBaseUrl, restFetchOptions);
103
+ const csrf = sessionStorage.getItem(csrfStorageKey);
104
+ if (csrf) authHeaders["x-csrf-token"] = csrf;
105
+ }
106
+
107
+ // Persist a stable external_user_id per browser for session stitching.
108
+ const storageKey = "polymorph_widget_session";
109
+ let externalUserId = localStorage.getItem(storageKey) ?? undefined;
110
+ if (!externalUserId) {
111
+ externalUserId = `widget-session-${crypto.randomUUID().slice(0, 12)}`;
112
+ localStorage.setItem(storageKey, externalUserId);
53
113
  }
114
+
54
115
  const response = await fetch(`${config.apiBaseUrl}/voice-rooms/start`, {
55
116
  method: "POST",
56
117
  headers: {
@@ -61,15 +122,29 @@ export function usePolymorphSession(config: WidgetConfig) {
61
122
  : extraHeaders),
62
123
  },
63
124
  body: JSON.stringify({
64
- agent_name: "custom-voice-agent",
65
- metadata: config.metadata,
66
- user_identity: config.userIdentity,
67
- user_name: config.userName,
125
+ 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,
139
+ external_user_id: externalUserId,
68
140
  }),
69
141
  ...restFetchOptions,
70
142
  });
143
+ if (response.status === 403) {
144
+ sessionStorage.removeItem(csrfStorageKey);
145
+ }
71
146
  if (!response.ok) {
72
- throw new Error(await response.text() || "Failed to start session");
147
+ throw new Error((await response.text()) || "Failed to start session");
73
148
  }
74
149
  const data = await response.json();
75
150
  setRoomConnection({ token: data.token, livekitUrl: data.livekit_url });
@@ -86,13 +161,28 @@ export function usePolymorphSession(config: WidgetConfig) {
86
161
  setStatus("idle");
87
162
  }, []);
88
163
 
89
- const sendMessage = useCallback((text: string) => {
90
- const room = roomRef.current;
91
- if (!room || !text.trim()) return;
92
- addMessage("user", text.trim(), "chat");
93
- const payload = new TextEncoder().encode(JSON.stringify({ text: text.trim() }));
94
- room.localParticipant.publishData(payload, { reliable: true, topic: "chat_message" });
95
- }, [addMessage]);
164
+ const sendMessage = useCallback(
165
+ (text: string) => {
166
+ if (!text.trim()) return;
167
+ const trimmed = text.trim();
168
+ addMessage("user", trimmed, "chat");
169
+
170
+ const room = roomRef.current;
171
+ if (room) {
172
+ const payload = new TextEncoder().encode(
173
+ JSON.stringify({ text: trimmed }),
174
+ );
175
+ room.localParticipant.publishData(payload, {
176
+ reliable: true,
177
+ topic: "chat_message",
178
+ });
179
+ } else {
180
+ pendingMessagesRef.current.push(trimmed);
181
+ void connect();
182
+ }
183
+ },
184
+ [addMessage, connect],
185
+ );
96
186
 
97
187
  const toggleMic = useCallback(async () => {
98
188
  const room = roomRef.current;
@@ -109,11 +199,29 @@ export function usePolymorphSession(config: WidgetConfig) {
109
199
  await room.localParticipant.setMicrophoneEnabled(newState);
110
200
  setIsVoiceEnabled(newState);
111
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
+ });
112
209
  }, [isVoiceEnabled]);
113
210
 
114
211
  const setRoom = useCallback((room: Room | null) => {
115
212
  roomRef.current = room;
116
- if (room) setStatus("connected");
213
+ if (room) {
214
+ setStatus("connected");
215
+ // Flush any messages queued before the room was ready
216
+ 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
+ });
223
+ }
224
+ }
117
225
  }, []);
118
226
 
119
227
  return {