@webotme/react-native 0.1.1
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 +189 -0
- package/package.json +46 -0
- package/src/AutoFlowExecutor.tsx +353 -0
- package/src/client.ts +265 -0
- package/src/index.ts +27 -0
- package/src/launcher.tsx +93 -0
- package/src/provider.tsx +238 -0
- package/src/types.ts +151 -0
- package/src/widget.tsx +647 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WebotMeError,
|
|
3
|
+
type WebotMeAction,
|
|
4
|
+
type WebotMeConfig,
|
|
5
|
+
type WebotMeContract,
|
|
6
|
+
type WebotMeErrorCode,
|
|
7
|
+
type WebotMeScreenContext,
|
|
8
|
+
type WebotMePendingWorkflow,
|
|
9
|
+
type WebotMeAutoTask,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export type SendResult = {
|
|
13
|
+
text: string;
|
|
14
|
+
reply?: string;
|
|
15
|
+
type: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
provider?: string;
|
|
18
|
+
action?: WebotMeAction;
|
|
19
|
+
actions?: WebotMeAction[];
|
|
20
|
+
pendingWorkflow?: WebotMePendingWorkflow;
|
|
21
|
+
autoTask?: WebotMeAutoTask;
|
|
22
|
+
raw: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thin client for the versioned native contract.
|
|
27
|
+
*
|
|
28
|
+
* Auth is the bot ID only — never ship an API key in a mobile app, because
|
|
29
|
+
* apps can be decompiled. Server-to-server callers should use /api/sdk/chat
|
|
30
|
+
* with a key held on their own backend.
|
|
31
|
+
*/
|
|
32
|
+
export class WebotMeClient {
|
|
33
|
+
private apiBaseUrl: string;
|
|
34
|
+
private botId: string;
|
|
35
|
+
private timeoutMs: number;
|
|
36
|
+
|
|
37
|
+
constructor(config: Pick<WebotMeConfig, "botId" | "apiBaseUrl" | "timeoutMs">) {
|
|
38
|
+
if (!config.botId) throw new WebotMeError("INVALID_REQUEST", "botId is required");
|
|
39
|
+
if (!config.apiBaseUrl)
|
|
40
|
+
throw new WebotMeError("INVALID_REQUEST", "apiBaseUrl is required");
|
|
41
|
+
|
|
42
|
+
this.botId = config.botId;
|
|
43
|
+
this.apiBaseUrl = config.apiBaseUrl.replace(/\/+$/, "");
|
|
44
|
+
this.timeoutMs = config.timeoutMs ?? 30000;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private url(path: string) {
|
|
48
|
+
return `${this.apiBaseUrl}/api/chatbot/public/${this.botId}${path}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async request<T>(
|
|
52
|
+
path: string,
|
|
53
|
+
init: RequestInit & { method: "GET" | "POST" },
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
57
|
+
|
|
58
|
+
let response: Response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(this.url(path), {
|
|
61
|
+
...init,
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
headers: {
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
...(init.headers || {}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const aborted = (err as Error)?.name === "AbortError";
|
|
70
|
+
throw new WebotMeError(
|
|
71
|
+
"NETWORK_ERROR",
|
|
72
|
+
aborted
|
|
73
|
+
? `Request timed out after ${this.timeoutMs}ms`
|
|
74
|
+
: `Cannot reach ${this.apiBaseUrl}. Check the server URL and your network.`,
|
|
75
|
+
);
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
let body: any = null;
|
|
82
|
+
if (text) {
|
|
83
|
+
try {
|
|
84
|
+
body = JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
body = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const code = (body?.code as WebotMeErrorCode) || "INTERNAL_ERROR";
|
|
92
|
+
const message =
|
|
93
|
+
body?.message || `Request failed with status ${response.status}`;
|
|
94
|
+
throw new WebotMeError(code, message, response.status);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return body as T;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Feature-detect the server before sending anything. */
|
|
101
|
+
discover(): Promise<WebotMeContract> {
|
|
102
|
+
return this.request<WebotMeContract>("/native-chat/v1", { method: "GET" });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Public bot config: name, theme, welcome message. */
|
|
106
|
+
settings(): Promise<Record<string, unknown>> {
|
|
107
|
+
return this.request<Record<string, unknown>>("", { method: "GET" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async send(params: {
|
|
111
|
+
message: string;
|
|
112
|
+
sessionId: string;
|
|
113
|
+
userId: string;
|
|
114
|
+
screenContext?: WebotMeScreenContext;
|
|
115
|
+
}): Promise<SendResult> {
|
|
116
|
+
const body = await this.request<Record<string, unknown>>("/native-chat/v1", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
message: params.message,
|
|
120
|
+
sessionId: params.sessionId,
|
|
121
|
+
userId: params.userId,
|
|
122
|
+
...(params.screenContext ? { screenContext: params.screenContext } : {}),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// v1 always returns `text`; `reply` is kept as an alias by the server.
|
|
127
|
+
const text =
|
|
128
|
+
(typeof body.text === "string" && body.text) ||
|
|
129
|
+
(typeof body.reply === "string" && body.reply) ||
|
|
130
|
+
"";
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
text,
|
|
134
|
+
reply: typeof body.reply === "string" ? body.reply : undefined,
|
|
135
|
+
type: typeof body.type === "string" ? body.type : "message",
|
|
136
|
+
version: typeof body.version === "string" ? body.version : undefined,
|
|
137
|
+
provider:
|
|
138
|
+
typeof body.provider === "string" ? body.provider : undefined,
|
|
139
|
+
action: normalizeAction(body),
|
|
140
|
+
actions: (body.actions as any[]) || (body.action ? [normalizeAction(body)].filter(Boolean) : undefined),
|
|
141
|
+
pendingWorkflow: (body.pendingWorkflow as any) || undefined,
|
|
142
|
+
autoTask: (body.autoTask as any) || undefined,
|
|
143
|
+
raw: body,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Past sessions for this visitor, newest first. */
|
|
148
|
+
async sessions(visitorId: string): Promise<unknown[]> {
|
|
149
|
+
const body = await this.request<{ sessions?: unknown[] } | unknown[]>(
|
|
150
|
+
`/sessions/${encodeURIComponent(visitorId)}`,
|
|
151
|
+
{ method: "GET" },
|
|
152
|
+
);
|
|
153
|
+
return Array.isArray(body) ? body : (body?.sessions ?? []);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
startSession(): Promise<{ sessionId: string }> {
|
|
157
|
+
return this.request<{ sessionId: string }>("/session/new", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: JSON.stringify({}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Speech-to-Text: send audio blob, get transcribed text.
|
|
165
|
+
* @param audioBlob - Audio recording (webm, wav, mp4, etc.)
|
|
166
|
+
* @param mimeType - MIME type of the audio (e.g., "audio/webm", "audio/wav")
|
|
167
|
+
* @returns Transcribed text
|
|
168
|
+
*/
|
|
169
|
+
async speechToText(audioBlob: Blob, mimeType = "audio/webm"): Promise<{ text: string }> {
|
|
170
|
+
const formData = new FormData();
|
|
171
|
+
formData.append("audio", audioBlob, `recording.${mimeType.split("/")[1] || "webm"}`);
|
|
172
|
+
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(this.url("/native-chat/v1/stt"), {
|
|
178
|
+
method: "POST",
|
|
179
|
+
body: formData,
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
});
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
|
|
184
|
+
const text = await response.text();
|
|
185
|
+
let body: any = null;
|
|
186
|
+
if (text) {
|
|
187
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const code = (body?.code as WebotMeErrorCode) || "INTERNAL_ERROR";
|
|
192
|
+
const message = body?.message || `STT failed with status ${response.status}`;
|
|
193
|
+
throw new WebotMeError(code, message, response.status);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { text: body?.text || body?.transcript || "" };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
const aborted = (err as Error)?.name === "AbortError";
|
|
200
|
+
throw new WebotMeError(
|
|
201
|
+
"NETWORK_ERROR",
|
|
202
|
+
aborted
|
|
203
|
+
? `STT request timed out after ${this.timeoutMs}ms`
|
|
204
|
+
: `Cannot reach STT endpoint. Check the server URL and your network.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Text-to-Speech: send text, get audio blob.
|
|
211
|
+
* @param text - Text to synthesize
|
|
212
|
+
* @param voice - Optional voice identifier (provider-specific)
|
|
213
|
+
* @returns Audio blob (mp3, wav, etc.)
|
|
214
|
+
*/
|
|
215
|
+
async textToSpeech(text: string, voice?: string): Promise<Blob> {
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(this.url("/native-chat/v1/tts"), {
|
|
221
|
+
method: "POST",
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
headers: { "Content-Type": "application/json" },
|
|
224
|
+
body: JSON.stringify({ text, voice }),
|
|
225
|
+
});
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
const text = await response.text();
|
|
230
|
+
let body: any = null;
|
|
231
|
+
if (text) { try { body = JSON.parse(text); } catch { body = null; } }
|
|
232
|
+
const code = (body?.code as WebotMeErrorCode) || "INTERNAL_ERROR";
|
|
233
|
+
const message = body?.message || `TTS failed with status ${response.status}`;
|
|
234
|
+
throw new WebotMeError(code, message, response.status);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return await response.blob();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
const aborted = (err as Error)?.name === "AbortError";
|
|
241
|
+
throw new WebotMeError(
|
|
242
|
+
"NETWORK_ERROR",
|
|
243
|
+
aborted
|
|
244
|
+
? `TTS request timed out after ${this.timeoutMs}ms`
|
|
245
|
+
: `Cannot reach TTS endpoint. Check the server URL and your network.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The server may express the next AutoFlow action under any of a few keys
|
|
253
|
+
* depending on the flow, so pick the first one that looks actionable.
|
|
254
|
+
*/
|
|
255
|
+
function normalizeAction(
|
|
256
|
+
body: Record<string, unknown>,
|
|
257
|
+
): WebotMeAction | undefined {
|
|
258
|
+
const candidates = [body.action, body.nextAction, body.flowAction];
|
|
259
|
+
for (const candidate of candidates) {
|
|
260
|
+
if (candidate && typeof candidate === "object") {
|
|
261
|
+
return candidate as WebotMeAction;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { WebotMeProvider, useWebotMe, useWebotMeChat } from "./provider";
|
|
2
|
+
export type { WebotMeProviderProps } from "./provider";
|
|
3
|
+
|
|
4
|
+
export { WebotMeWidget } from "./widget";
|
|
5
|
+
export type { WebotMeWidgetProps, WidgetMode } from "./widget";
|
|
6
|
+
|
|
7
|
+
export { WebotMeLauncher } from "./launcher";
|
|
8
|
+
export type { WebotMeLauncherProps, LauncherPosition } from "./launcher";
|
|
9
|
+
|
|
10
|
+
export { WebotMeClient } from "./client";
|
|
11
|
+
export type { SendResult } from "./client";
|
|
12
|
+
|
|
13
|
+
export { WebotMeError } from "./types";
|
|
14
|
+
export type {
|
|
15
|
+
WebotMeAction,
|
|
16
|
+
WebotMeButton,
|
|
17
|
+
WebotMeCapabilities,
|
|
18
|
+
WebotMeConfig,
|
|
19
|
+
WebotMeContract,
|
|
20
|
+
WebotMeErrorCode,
|
|
21
|
+
WebotMeField,
|
|
22
|
+
WebotMeLink,
|
|
23
|
+
WebotMeList,
|
|
24
|
+
WebotMeMessage,
|
|
25
|
+
WebotMeScreenContext,
|
|
26
|
+
WebotMeTheme,
|
|
27
|
+
} from "./types";
|
package/src/launcher.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { WebotMeWidget } from "./widget";
|
|
4
|
+
import { useWebotMe } from "./provider";
|
|
5
|
+
import type { WebotMeTheme } from "./types";
|
|
6
|
+
|
|
7
|
+
export type LauncherPosition = "bottom-left" | "bottom-right";
|
|
8
|
+
|
|
9
|
+
export type WebotMeLauncherProps = {
|
|
10
|
+
position?: LauncherPosition;
|
|
11
|
+
bottom?: number;
|
|
12
|
+
side?: number;
|
|
13
|
+
label?: string;
|
|
14
|
+
icon?: string;
|
|
15
|
+
theme?: WebotMeTheme;
|
|
16
|
+
/** Hide the dot after the first screen view. */
|
|
17
|
+
showBadge?: boolean;
|
|
18
|
+
widgetProps?: React.ComponentProps<typeof WebotMeWidget>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function WebotMeLauncher({
|
|
22
|
+
position = "bottom-right",
|
|
23
|
+
bottom = 24,
|
|
24
|
+
side = 24,
|
|
25
|
+
label = "Chat",
|
|
26
|
+
icon = "💬",
|
|
27
|
+
theme,
|
|
28
|
+
showBadge = true,
|
|
29
|
+
widgetProps,
|
|
30
|
+
}: WebotMeLauncherProps) {
|
|
31
|
+
const { messages } = useWebotMe();
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
|
|
34
|
+
const primary = theme?.primary || "#4f46e5";
|
|
35
|
+
const unread = showBadge && messages.some((m) => m.from === "bot" && m.text);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<View
|
|
40
|
+
pointerEvents="box-none"
|
|
41
|
+
style={[
|
|
42
|
+
styles.wrap,
|
|
43
|
+
position === "bottom-left" ? { left: side } : { right: side },
|
|
44
|
+
{ bottom },
|
|
45
|
+
]}
|
|
46
|
+
>
|
|
47
|
+
<Pressable
|
|
48
|
+
onPress={() => setOpen(true)}
|
|
49
|
+
accessibilityLabel={label}
|
|
50
|
+
style={({ pressed }) => [
|
|
51
|
+
styles.bubble,
|
|
52
|
+
{ backgroundColor: primary, opacity: pressed ? 0.85 : 1 },
|
|
53
|
+
]}
|
|
54
|
+
>
|
|
55
|
+
<Text style={styles.icon}>{icon}</Text>
|
|
56
|
+
{unread ? <View style={styles.badge} /> : null}
|
|
57
|
+
</Pressable>
|
|
58
|
+
</View>
|
|
59
|
+
|
|
60
|
+
<WebotMeWidget
|
|
61
|
+
{...widgetProps}
|
|
62
|
+
visible={open}
|
|
63
|
+
onClose={() => setOpen(false)}
|
|
64
|
+
/>
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create({
|
|
70
|
+
wrap: { position: "absolute", zIndex: 100 },
|
|
71
|
+
bubble: {
|
|
72
|
+
width: 56,
|
|
73
|
+
height: 56,
|
|
74
|
+
borderRadius: 28,
|
|
75
|
+
alignItems: "center",
|
|
76
|
+
justifyContent: "center",
|
|
77
|
+
elevation: 6,
|
|
78
|
+
shadowColor: "#000",
|
|
79
|
+
shadowOpacity: 0.25,
|
|
80
|
+
shadowRadius: 8,
|
|
81
|
+
shadowOffset: { width: 0, height: 4 },
|
|
82
|
+
},
|
|
83
|
+
icon: { fontSize: 24 },
|
|
84
|
+
badge: {
|
|
85
|
+
position: "absolute",
|
|
86
|
+
top: 4,
|
|
87
|
+
right: 4,
|
|
88
|
+
width: 12,
|
|
89
|
+
height: 12,
|
|
90
|
+
borderRadius: 6,
|
|
91
|
+
backgroundColor: "#ef4444",
|
|
92
|
+
},
|
|
93
|
+
});
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { WebotMeClient } from "./client";
|
|
10
|
+
import type {
|
|
11
|
+
WebotMeConfig,
|
|
12
|
+
WebotMeContract,
|
|
13
|
+
WebotMeMessage,
|
|
14
|
+
WebotMeAction,
|
|
15
|
+
WebotMePendingWorkflow,
|
|
16
|
+
WebotMeAutoTask,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
type WebotMeContextValue = {
|
|
20
|
+
client: WebotMeClient;
|
|
21
|
+
botId: string;
|
|
22
|
+
apiBaseUrl: string;
|
|
23
|
+
/** Shareable page that renders the bot inside a WebView. */
|
|
24
|
+
chatUrl: string;
|
|
25
|
+
userId: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
messages: WebotMeMessage[];
|
|
28
|
+
sending: boolean;
|
|
29
|
+
error: string | null;
|
|
30
|
+
contract: WebotMeContract | null;
|
|
31
|
+
send: (text: string) => Promise<void>;
|
|
32
|
+
retry: () => void;
|
|
33
|
+
reset: () => void;
|
|
34
|
+
/** Current pending multi-step workflow from AutoFlow. */
|
|
35
|
+
pendingWorkflow: WebotMePendingWorkflow | null;
|
|
36
|
+
/** Current autonomous task from AutoFlow. */
|
|
37
|
+
autoTask: WebotMeAutoTask | null;
|
|
38
|
+
/** Callback when an action is executed. */
|
|
39
|
+
onActionExecuted?: (action: WebotMeAction, success: boolean, error?: string) => void;
|
|
40
|
+
/** Callback when workflow state changes. */
|
|
41
|
+
onWorkflowUpdate?: (workflow: WebotMePendingWorkflow | null) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const WebotMeContext = createContext<WebotMeContextValue | null>(null);
|
|
45
|
+
|
|
46
|
+
export function useWebotMe() {
|
|
47
|
+
const ctx = useContext(WebotMeContext);
|
|
48
|
+
if (!ctx) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"useWebotMe must be used inside <WebotMeProvider>. Wrap your app root first.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return ctx;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Optional hook for screens that only need to send or read messages. */
|
|
57
|
+
export function useWebotMeChat() {
|
|
58
|
+
const { messages, send, sending, error, retry } = useWebotMe();
|
|
59
|
+
return { messages, send, sending, error, retry };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const makeId = (prefix: string) =>
|
|
63
|
+
`${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
64
|
+
|
|
65
|
+
export type WebotMeProviderProps = WebotMeConfig & {
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
/** Change this to wipe the local conversation and start a new session. */
|
|
68
|
+
resetKey?: string | number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function WebotMeProvider({
|
|
72
|
+
children,
|
|
73
|
+
botId,
|
|
74
|
+
apiBaseUrl,
|
|
75
|
+
chatBaseUrl: chatBaseUrlProp,
|
|
76
|
+
userId,
|
|
77
|
+
theme,
|
|
78
|
+
welcome,
|
|
79
|
+
screenContext,
|
|
80
|
+
timeoutMs,
|
|
81
|
+
resetKey,
|
|
82
|
+
}: WebotMeProviderProps) {
|
|
83
|
+
const client = useMemo(
|
|
84
|
+
() => new WebotMeClient({ botId, apiBaseUrl, timeoutMs }),
|
|
85
|
+
[botId, apiBaseUrl, timeoutMs],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Stable visitor id for the lifetime of the app install.
|
|
89
|
+
const visitorRef = useRef<string>(userId || makeId("v"));
|
|
90
|
+
const [sessionId, setSessionId] = useState(() => makeId("s"));
|
|
91
|
+
const [messages, setMessages] = useState<WebotMeMessage[]>([]);
|
|
92
|
+
const [sending, setSending] = useState(false);
|
|
93
|
+
const [error, setError] = useState<string | null>(null);
|
|
94
|
+
const [contract, setContract] = useState<WebotMeContract | null>(null);
|
|
95
|
+
const [attempt, setAttempt] = useState(0);
|
|
96
|
+
const [pendingWorkflow, setPendingWorkflow] = useState<WebotMePendingWorkflow | null>(null);
|
|
97
|
+
const [autoTask, setAutoTask] = useState<WebotMeAutoTask | null>(null);
|
|
98
|
+
|
|
99
|
+
const chatBaseUrl = useMemo(
|
|
100
|
+
() => (chatBaseUrlProp || apiBaseUrl).replace(/\/+$/, ""),
|
|
101
|
+
[chatBaseUrlProp, apiBaseUrl],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const chatUrl = `${chatBaseUrl}/chat/${botId}`;
|
|
105
|
+
|
|
106
|
+
// Feature-detect once so the app can show a clear message on an old server.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
let active = true;
|
|
109
|
+
client
|
|
110
|
+
.discover()
|
|
111
|
+
.then((c) => {
|
|
112
|
+
if (active) setContract(c);
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {
|
|
115
|
+
if (active) setContract(null);
|
|
116
|
+
});
|
|
117
|
+
return () => {
|
|
118
|
+
active = false;
|
|
119
|
+
};
|
|
120
|
+
}, [client, attempt]);
|
|
121
|
+
|
|
122
|
+
// New session when the caller bumps resetKey.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (resetKey === undefined) return;
|
|
125
|
+
setSessionId(makeId("s"));
|
|
126
|
+
setMessages(welcome ? [botMessage(welcome)] : []);
|
|
127
|
+
setError(null);
|
|
128
|
+
setPendingWorkflow(null);
|
|
129
|
+
setAutoTask(null);
|
|
130
|
+
}, [resetKey, welcome]);
|
|
131
|
+
|
|
132
|
+
const send = async (text: string) => {
|
|
133
|
+
const trimmed = text.trim();
|
|
134
|
+
if (!trimmed || sending) return;
|
|
135
|
+
|
|
136
|
+
const userMsg: WebotMeMessage = {
|
|
137
|
+
id: makeId("m"),
|
|
138
|
+
from: "user",
|
|
139
|
+
text: trimmed,
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
const pendingId = makeId("m");
|
|
143
|
+
setMessages((prev) => [
|
|
144
|
+
...prev,
|
|
145
|
+
userMsg,
|
|
146
|
+
{ id: pendingId, from: "bot", text: "", createdAt: Date.now(), pending: true },
|
|
147
|
+
]);
|
|
148
|
+
setSending(true);
|
|
149
|
+
setError(null);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const result = await client.send({
|
|
153
|
+
message: trimmed,
|
|
154
|
+
sessionId,
|
|
155
|
+
userId: visitorRef.current,
|
|
156
|
+
screenContext,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Handle new response format with actions, pendingWorkflow, autoTask
|
|
160
|
+
const botMsg: WebotMeMessage = {
|
|
161
|
+
id: pendingId,
|
|
162
|
+
from: "bot",
|
|
163
|
+
text: result.text || result.reply || "",
|
|
164
|
+
createdAt: Date.now(),
|
|
165
|
+
action: result.action ? result.actions?.[0] : undefined,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
setMessages((prev) =>
|
|
169
|
+
prev.map((m) =>
|
|
170
|
+
m.id === pendingId ? botMsg : m,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Update workflow state
|
|
175
|
+
if (result.pendingWorkflow) {
|
|
176
|
+
setPendingWorkflow(result.pendingWorkflow);
|
|
177
|
+
} else if (result.autoTask) {
|
|
178
|
+
setAutoTask(result.autoTask);
|
|
179
|
+
} else if (!result.pendingWorkflow && !result.autoTask) {
|
|
180
|
+
// Only clear if server explicitly says no workflow
|
|
181
|
+
setPendingWorkflow(null);
|
|
182
|
+
setAutoTask(null);
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const message = err instanceof Error ? err.message : "Something went wrong";
|
|
186
|
+
setError(message);
|
|
187
|
+
setMessages((prev) => prev.filter((m) => m.id !== pendingId));
|
|
188
|
+
} finally {
|
|
189
|
+
setSending(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const retry = () => setAttempt((n) => n + 1);
|
|
194
|
+
const reset = () => setSessionId(makeId("s"));
|
|
195
|
+
|
|
196
|
+
const value = useMemo<WebotMeContextValue>(
|
|
197
|
+
() => ({
|
|
198
|
+
client,
|
|
199
|
+
botId,
|
|
200
|
+
apiBaseUrl,
|
|
201
|
+
chatUrl,
|
|
202
|
+
userId: visitorRef.current,
|
|
203
|
+
sessionId,
|
|
204
|
+
messages,
|
|
205
|
+
sending,
|
|
206
|
+
error,
|
|
207
|
+
contract,
|
|
208
|
+
send,
|
|
209
|
+
retry,
|
|
210
|
+
reset,
|
|
211
|
+
pendingWorkflow,
|
|
212
|
+
autoTask,
|
|
213
|
+
}),
|
|
214
|
+
[
|
|
215
|
+
client,
|
|
216
|
+
botId,
|
|
217
|
+
apiBaseUrl,
|
|
218
|
+
chatUrl,
|
|
219
|
+
sessionId,
|
|
220
|
+
messages,
|
|
221
|
+
sending,
|
|
222
|
+
error,
|
|
223
|
+
contract,
|
|
224
|
+
pendingWorkflow,
|
|
225
|
+
autoTask,
|
|
226
|
+
],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<WebotMeContext.Provider value={value}>{children}</WebotMeContext.Provider>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function botMessage(text: string): WebotMeMessage {
|
|
235
|
+
return { id: makeId("m"), from: "bot", text, createdAt: Date.now() };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type { WebotMeTheme } from "./types";
|