@webotme/react-native 0.1.1 → 0.1.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/README.md +79 -0
- package/package.json +16 -5
- package/src/AutoFlowExecutor.tsx +19 -8
- package/src/chat.ts +371 -0
- package/src/client.ts +4 -1
- package/src/executor.ts +190 -0
- package/src/index.ts +57 -0
- package/src/lazy-webview.tsx +59 -0
- package/src/navigationAdapter.ts +117 -0
- package/src/provider.tsx +103 -76
- package/src/runtime.ts +35 -0
- package/src/safety.ts +72 -0
- package/src/scan.txt +1 -0
- package/src/screenHook.ts +82 -0
- package/src/screenScanner.tsx +382 -0
- package/src/types.ts +2 -0
- package/src/voice.ts +722 -0
- package/src/widget.tsx +3 -2
package/src/executor.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Alert, Linking } from "react-native";
|
|
2
|
+
import { getChatState, nextAutoFlowStep, subscribeChat } from "./chat";
|
|
3
|
+
import { isActionAllowed } from "./safety";
|
|
4
|
+
import { findClickHandler, findFillHandler } from "./screenScanner";
|
|
5
|
+
import { routeTargetFromUrl, pushRoute, getCurrentRoute } from "./navigationAdapter";
|
|
6
|
+
import type { WebotMeAction, WebotMeMessage } from "./types";
|
|
7
|
+
|
|
8
|
+
export type ExecutorOptions = {
|
|
9
|
+
/** Called to navigate; defaults to expo-router when available. */
|
|
10
|
+
navigate?: (routeName: string, params?: Record<string, any>) => void;
|
|
11
|
+
goBack?: () => void;
|
|
12
|
+
push?: (routeName: string, params?: Record<string, any>) => void;
|
|
13
|
+
/** Called after every action attempt. */
|
|
14
|
+
onActionExecuted?: (action: WebotMeAction, success: boolean, error?: string) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let handlers: ExecutorOptions | null = null;
|
|
18
|
+
let currentUnsub: (() => void) | null = null;
|
|
19
|
+
const executedMessageIds = new Set<string>();
|
|
20
|
+
|
|
21
|
+
function defaultNavigate(routeName: string, fallbackName?: string, params?: Record<string, any>) {
|
|
22
|
+
if (pushRoute(routeName, params)) return true;
|
|
23
|
+
if (fallbackName && pushRoute(fallbackName, params)) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Run a single server action on the device. */
|
|
28
|
+
export async function executeAction(action: WebotMeAction): Promise<{ success: boolean; error?: string }> {
|
|
29
|
+
const doNavigate = handlers?.navigate;
|
|
30
|
+
try {
|
|
31
|
+
// Block/confirm at the gate before doing anything on the device.
|
|
32
|
+
const allowed = await isActionAllowed(action);
|
|
33
|
+
if (!allowed) return { success: false, error: "Blocked or declined by safety layer" };
|
|
34
|
+
|
|
35
|
+
switch (action.type) {
|
|
36
|
+
case "navigate": {
|
|
37
|
+
const url = (action.data as any)?.url;
|
|
38
|
+
if (!url) throw new Error("Navigate action missing URL");
|
|
39
|
+
// Resolve the raw URL (full URL, path, or native://screen) into a real
|
|
40
|
+
// route path so router.push actually moves the app — the friendly name
|
|
41
|
+
// is only used as a fallback for React Navigation named screens.
|
|
42
|
+
const target = routeTargetFromUrl(url);
|
|
43
|
+
const params = {
|
|
44
|
+
fromAutoFlow: true,
|
|
45
|
+
originalUrl: url,
|
|
46
|
+
screenName: target.name,
|
|
47
|
+
route: target.route,
|
|
48
|
+
};
|
|
49
|
+
if (doNavigate) {
|
|
50
|
+
doNavigate(target.route, params);
|
|
51
|
+
return { success: true };
|
|
52
|
+
}
|
|
53
|
+
if (handlers?.push) {
|
|
54
|
+
handlers.push(target.route, params);
|
|
55
|
+
return { success: true };
|
|
56
|
+
}
|
|
57
|
+
const before = getCurrentRoute();
|
|
58
|
+
const ok = defaultNavigate(target.route, target.name, params);
|
|
59
|
+
if (!ok) {
|
|
60
|
+
// No expo-router stack is available — the app may use tabs or a
|
|
61
|
+
// custom layout. Fall back to tapping a matching control on-screen
|
|
62
|
+
// (e.g. the "Settings" tab) so the bot's promise still lands.
|
|
63
|
+
const tapper =
|
|
64
|
+
findClickHandler(target.name) ||
|
|
65
|
+
findClickHandler((action.data as any)?.label) ||
|
|
66
|
+
findClickHandler((action.data as any)?.text);
|
|
67
|
+
if (tapper) {
|
|
68
|
+
tapper();
|
|
69
|
+
return { success: true };
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`No navigation handler for "${url}"`);
|
|
72
|
+
}
|
|
73
|
+
// Give expo-router a beat to move; if nothing changed, the route likely
|
|
74
|
+
// doesn't exist, so tap a same-named tab/button instead of reporting a
|
|
75
|
+
// success that never happened.
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
77
|
+
const after = getCurrentRoute();
|
|
78
|
+
if (after === before && after !== target.route) {
|
|
79
|
+
const tapper =
|
|
80
|
+
findClickHandler(target.name) ||
|
|
81
|
+
findClickHandler((action.data as any)?.label) ||
|
|
82
|
+
findClickHandler((action.data as any)?.text);
|
|
83
|
+
if (tapper) {
|
|
84
|
+
tapper();
|
|
85
|
+
return { success: true };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { success: true };
|
|
89
|
+
}
|
|
90
|
+
case "click": {
|
|
91
|
+
const label =
|
|
92
|
+
(action.data as any)?.text ??
|
|
93
|
+
(action.data as any)?.selector ??
|
|
94
|
+
(action.data as any)?.label;
|
|
95
|
+
const h = findClickHandler(label);
|
|
96
|
+
if (h) {
|
|
97
|
+
h();
|
|
98
|
+
return { success: true };
|
|
99
|
+
}
|
|
100
|
+
console.warn("[WebotMe executor] click action has no ref binding:", action.data);
|
|
101
|
+
return { success: true };
|
|
102
|
+
}
|
|
103
|
+
case "fill": {
|
|
104
|
+
const label =
|
|
105
|
+
(action.data as any)?.label ??
|
|
106
|
+
(action.data as any)?.selector ??
|
|
107
|
+
(action.data as any)?.name;
|
|
108
|
+
const value = (action.data as any)?.text;
|
|
109
|
+
const setter = findFillHandler(label);
|
|
110
|
+
if (setter) {
|
|
111
|
+
setter(typeof value === "string" ? value : String(value ?? ""));
|
|
112
|
+
return { success: true };
|
|
113
|
+
}
|
|
114
|
+
console.log(
|
|
115
|
+
`[WebotMe executor] fill "${label}" -> "${value}" -- no matching input found.`,
|
|
116
|
+
);
|
|
117
|
+
return { success: true };
|
|
118
|
+
}
|
|
119
|
+
case "checkout": {
|
|
120
|
+
const url = (action.data as any)?.url;
|
|
121
|
+
if (!url) throw new Error("Checkout action missing URL");
|
|
122
|
+
await Linking.openURL(url);
|
|
123
|
+
return { success: true };
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return { success: false, error: `Unknown action type: ${(action as any).type}` };
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Run the actions a bot returns on its message, in order. */
|
|
134
|
+
export async function executeActions(actions: WebotMeAction[]): Promise<void> {
|
|
135
|
+
for (const action of actions.slice(0, 20)) {
|
|
136
|
+
const result = await executeAction(action);
|
|
137
|
+
handlers?.onActionExecuted?.(action, result.success, result.error);
|
|
138
|
+
if (!result.success) {
|
|
139
|
+
Alert.alert("Action Failed", result.error || "Unknown error");
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Arm the executor: it watches the shared chat engine and runs each bot action
|
|
148
|
+
* exactly once. Returns a disposer.
|
|
149
|
+
*/
|
|
150
|
+
export function setupExecutor(options?: ExecutorOptions): () => void {
|
|
151
|
+
handlers = options ?? handlers ?? {};
|
|
152
|
+
if (currentUnsub) return currentUnsub;
|
|
153
|
+
const unsubscribe = subscribeChat(() => {
|
|
154
|
+
const { messages } = getChatState();
|
|
155
|
+
for (const message of messages) {
|
|
156
|
+
if (!message || message.from !== "bot" || executedMessageIds.has(message.id)) continue;
|
|
157
|
+
const actions = message.actions && message.actions.length > 0
|
|
158
|
+
? message.actions
|
|
159
|
+
: message.action
|
|
160
|
+
? [message.action]
|
|
161
|
+
: [];
|
|
162
|
+
if (actions.length === 0) continue;
|
|
163
|
+
executedMessageIds.add(message.id);
|
|
164
|
+
void executeActions(actions).then(() => {
|
|
165
|
+
// If we're inside an AutoFlow task, let the server decide the next step.
|
|
166
|
+
const state = getChatState();
|
|
167
|
+
if (state.autoTask && state.autoTask.goal) {
|
|
168
|
+
// Wait for the screen to settle so the next step sees fresh screenContext.
|
|
169
|
+
setTimeout(() => {
|
|
170
|
+
void nextAutoFlowStep();
|
|
171
|
+
}, 2500);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
currentUnsub = () => {
|
|
177
|
+
unsubscribe();
|
|
178
|
+
currentUnsub = null;
|
|
179
|
+
};
|
|
180
|
+
return currentUnsub;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function resetExecutorState() {
|
|
184
|
+
executedMessageIds.clear();
|
|
185
|
+
handlers = null;
|
|
186
|
+
if (currentUnsub) {
|
|
187
|
+
currentUnsub();
|
|
188
|
+
currentUnsub = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,64 @@ export type { WebotMeLauncherProps, LauncherPosition } from "./launcher";
|
|
|
10
10
|
export { WebotMeClient } from "./client";
|
|
11
11
|
export type { SendResult } from "./client";
|
|
12
12
|
|
|
13
|
+
export {
|
|
14
|
+
chatWithUs,
|
|
15
|
+
configureChat,
|
|
16
|
+
resetChat,
|
|
17
|
+
updateChatScreenContext,
|
|
18
|
+
useChat,
|
|
19
|
+
subscribeChat,
|
|
20
|
+
getChatState,
|
|
21
|
+
getChatSessionId,
|
|
22
|
+
} from "./chat";
|
|
23
|
+
export type { ChatResult } from "./chat";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
configureVoice,
|
|
27
|
+
isVoiceSupported,
|
|
28
|
+
setVoiceReplies,
|
|
29
|
+
speakReplyIfEnabled,
|
|
30
|
+
speakWithUs,
|
|
31
|
+
stopVoice,
|
|
32
|
+
useVoiceChat,
|
|
33
|
+
voiceWithUs,
|
|
34
|
+
} from "./voice";
|
|
35
|
+
export type { VoiceOptions, VoiceResult, VoiceState } from "./voice";
|
|
36
|
+
|
|
13
37
|
export { WebotMeError } from "./types";
|
|
38
|
+
export { ensureRuntime } from "./runtime";
|
|
39
|
+
export {
|
|
40
|
+
executeAction,
|
|
41
|
+
executeActions,
|
|
42
|
+
resetExecutorState,
|
|
43
|
+
setupExecutor,
|
|
44
|
+
type ExecutorOptions,
|
|
45
|
+
} from "./executor";
|
|
46
|
+
export {
|
|
47
|
+
getCurrentRoute,
|
|
48
|
+
goBack,
|
|
49
|
+
navigateTo,
|
|
50
|
+
pushRoute,
|
|
51
|
+
routeTargetFromUrl,
|
|
52
|
+
setCurrentRoute,
|
|
53
|
+
useNavigationAdapter,
|
|
54
|
+
} from "./navigationAdapter";
|
|
55
|
+
export {
|
|
56
|
+
isActionAllowed,
|
|
57
|
+
isBlocked,
|
|
58
|
+
needsConfirmation,
|
|
59
|
+
requireConfirmation,
|
|
60
|
+
setupSafetyLayer,
|
|
61
|
+
} from "./safety";
|
|
62
|
+
export {
|
|
63
|
+
registerScreenTree,
|
|
64
|
+
scanScreen,
|
|
65
|
+
setupFiberScanner,
|
|
66
|
+
stopFiberScanner,
|
|
67
|
+
useFiberScanner,
|
|
68
|
+
findClickHandler,
|
|
69
|
+
findFillHandler,
|
|
70
|
+
} from "./screenScanner";
|
|
14
71
|
export type {
|
|
15
72
|
WebotMeAction,
|
|
16
73
|
WebotMeButton,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `react-native-webview` is a native module, so importing it at the top of a
|
|
6
|
+
* file breaks any app that runs in Expo Go or a plain JS bundle. The web
|
|
7
|
+
* variant is the only thing that needs it, so it is resolved on first render
|
|
8
|
+
* instead — apps that only use `chatWithUs` never touch it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type AnyProps = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
function MissingWebView() {
|
|
14
|
+
return (
|
|
15
|
+
<View style={styles.wrap}>
|
|
16
|
+
<Text style={styles.title}>Web chat needs a development build</Text>
|
|
17
|
+
<Text style={styles.body}>
|
|
18
|
+
react-native-webview is a native module, so it is not available in Expo Go.
|
|
19
|
+
</Text>
|
|
20
|
+
<Text style={styles.body}>
|
|
21
|
+
Run "npx expo run:android" / "npx expo run:ios", or use an EAS build. If you
|
|
22
|
+
only need text chat, use variant="native" or the chatWithUs function.
|
|
23
|
+
</Text>
|
|
24
|
+
</View>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let resolved: React.ComponentType<AnyProps> | null = null;
|
|
29
|
+
|
|
30
|
+
export function loadWebView(): React.ComponentType<AnyProps> {
|
|
31
|
+
if (resolved) return resolved;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
|
+
const mod = require("react-native-webview");
|
|
36
|
+
const candidate = mod?.WebView ?? mod?.default ?? mod;
|
|
37
|
+
resolved =
|
|
38
|
+
candidate && (typeof candidate === "function" || typeof candidate === "object")
|
|
39
|
+
? (candidate as React.ComponentType<AnyProps>)
|
|
40
|
+
: MissingWebView;
|
|
41
|
+
} catch {
|
|
42
|
+
resolved = MissingWebView;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const styles = StyleSheet.create({
|
|
49
|
+
wrap: {
|
|
50
|
+
flex: 1,
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
justifyContent: "center",
|
|
53
|
+
gap: 8,
|
|
54
|
+
padding: 24,
|
|
55
|
+
backgroundColor: "#0f172a",
|
|
56
|
+
},
|
|
57
|
+
title: { color: "#f8fafc", fontSize: 15, fontWeight: "700", textAlign: "center" },
|
|
58
|
+
body: { color: "#94a3b8", fontSize: 13, textAlign: "center", lineHeight: 19 },
|
|
59
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
let lastRoute = "";
|
|
4
|
+
|
|
5
|
+
/** Last route seen by the app. */
|
|
6
|
+
export function getCurrentRoute(): string {
|
|
7
|
+
return lastRoute;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setCurrentRoute(route: string): void {
|
|
11
|
+
lastRoute = route;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toPascalName(route: string): string {
|
|
15
|
+
const trimmed = route.replace(/^\/+|\/+$/g, "");
|
|
16
|
+
if (!trimmed) return "Home";
|
|
17
|
+
const parts = trimmed.split("/").map((part) =>
|
|
18
|
+
part.charAt(0).toUpperCase() + part.slice(1),
|
|
19
|
+
);
|
|
20
|
+
return parts.join(":");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Turn any URL a server/AI returns into a navigation target that works with
|
|
25
|
+
* Expo Router (path-first) while keeping a friendly PascalCase name for apps
|
|
26
|
+
* using React Navigation named screens.
|
|
27
|
+
*
|
|
28
|
+
* - "https://host/book" -> { route: "/book", name: "Book" }
|
|
29
|
+
* - "/dashboard/settings" -> { route: "/dashboard/settings", name: "Dashboard:Settings" }
|
|
30
|
+
* - "native://Select Doctor" -> { route: "/select-doctor", name: "Select Doctor" }
|
|
31
|
+
* - "checkout" -> { route: "/checkout", name: "Checkout" }
|
|
32
|
+
*/
|
|
33
|
+
export function routeTargetFromUrl(input?: string | null): { route: string; name: string } {
|
|
34
|
+
let url = String(input ?? "").trim();
|
|
35
|
+
if (!url || url === "/") return { route: "/", name: "Home" };
|
|
36
|
+
|
|
37
|
+
// Native screen snapshots come in as native://<screen name>.
|
|
38
|
+
if (/^native:\/\//i.test(url)) {
|
|
39
|
+
const screenName = decodeURIComponent(url.slice("native://".length)) || "Home";
|
|
40
|
+
const slug = screenName
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/\s+/g, "-")
|
|
43
|
+
.replace(/[^a-z0-9\-_/]/g, "");
|
|
44
|
+
return { route: "/" + slug, name: screenName };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A full URL (from web scans) must become a bare path for expo-router.
|
|
48
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith("//")) {
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(url).pathname || "/";
|
|
51
|
+
} catch {
|
|
52
|
+
url = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, "") || "/";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Strip query string and hash.
|
|
57
|
+
url = url.replace(/[?#].*$/, "");
|
|
58
|
+
url = "/" + url.replace(/^\/+/, "");
|
|
59
|
+
url = url.replace(/\/{2,}/g, "/");
|
|
60
|
+
|
|
61
|
+
return { route: url, name: toPascalName(url) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Navigate imperatively using Expo Router's router. */
|
|
65
|
+
export function navigateTo(route: string, params?: Record<string, any>): void {
|
|
66
|
+
pushRoute(route, params);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Internal helper that reports whether a push actually happened. */
|
|
70
|
+
export function pushRoute(route: string, params?: Record<string, any>): boolean {
|
|
71
|
+
try {
|
|
72
|
+
const { route: target } = routeTargetFromUrl(route);
|
|
73
|
+
const router =
|
|
74
|
+
require("expo-router").default ?? require("expo-router").router;
|
|
75
|
+
const push = (router as any)?.push;
|
|
76
|
+
if (typeof push !== "function") return false;
|
|
77
|
+
if (params) push({ pathname: target as any, params } as any);
|
|
78
|
+
else push(target as any);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function goBack(): void {
|
|
86
|
+
try {
|
|
87
|
+
const router =
|
|
88
|
+
require("expo-router").default ?? require("expo-router").router;
|
|
89
|
+
(router as any)?.back?.();
|
|
90
|
+
} catch {
|
|
91
|
+
/* noop */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bridge the live Expo Router pathname into the scanner's screen map. Call
|
|
97
|
+
* this once from a component that sits inside the router context.
|
|
98
|
+
*/
|
|
99
|
+
export function useNavigationAdapter(): { route: string | undefined; back: () => void } {
|
|
100
|
+
let pathname: string | undefined;
|
|
101
|
+
try {
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
103
|
+
const usePathname = require("expo-router").usePathname as () => string | undefined;
|
|
104
|
+
// Expo Router provides this hook inside the app root; if it is missing the
|
|
105
|
+
// scanner/scanner route just stays blank.
|
|
106
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
107
|
+
pathname = usePathname();
|
|
108
|
+
} catch {
|
|
109
|
+
pathname = undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (pathname) setCurrentRoute(pathname);
|
|
114
|
+
}, [pathname]);
|
|
115
|
+
|
|
116
|
+
return { route: pathname, back: goBack };
|
|
117
|
+
}
|
package/src/provider.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
createContext,
|
|
3
|
+
useCallback,
|
|
3
4
|
useContext,
|
|
4
5
|
useEffect,
|
|
5
6
|
useMemo,
|
|
@@ -7,6 +8,18 @@ import React, {
|
|
|
7
8
|
useState,
|
|
8
9
|
} from "react";
|
|
9
10
|
import { WebotMeClient } from "./client";
|
|
11
|
+
import {
|
|
12
|
+
chatWithUs,
|
|
13
|
+
configureChat,
|
|
14
|
+
resetChat,
|
|
15
|
+
updateChatScreenContext,
|
|
16
|
+
useChat,
|
|
17
|
+
} from "./chat";
|
|
18
|
+
import { registerScreenTree, setupFiberScanner } from "./screenScanner";
|
|
19
|
+
import { setupExecutor } from "./executor";
|
|
20
|
+
import { setupSafetyLayer } from "./safety";
|
|
21
|
+
import { useNavigationAdapter } from "./navigationAdapter";
|
|
22
|
+
import { configureVoice } from "./voice";
|
|
10
23
|
import type {
|
|
11
24
|
WebotMeConfig,
|
|
12
25
|
WebotMeContract,
|
|
@@ -85,16 +98,50 @@ export function WebotMeProvider({
|
|
|
85
98
|
[botId, apiBaseUrl, timeoutMs],
|
|
86
99
|
);
|
|
87
100
|
|
|
101
|
+
// Feed the same bot into the one-function chat engine so `chatWithUs` works
|
|
102
|
+
// in apps that only mounted the provider and nothing else.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
configureChat({ botId, apiBaseUrl, userId, theme, welcome, screenContext, timeoutMs });
|
|
105
|
+
// Only re-run when the bot itself changes; message sends must not reset it.
|
|
106
|
+
}, [botId, apiBaseUrl, userId, welcome, timeoutMs]);
|
|
107
|
+
|
|
108
|
+
// Keep the element tree snapshot fresh for the screen scanner.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
registerScreenTree(children);
|
|
111
|
+
}, [children]);
|
|
112
|
+
|
|
113
|
+
// Auto-start the screen scanner so it can feed live screen context.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const stop = setupFiberScanner({ intervalMs: 2500 });
|
|
116
|
+
return stop;
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
// Arm the action executor so server-returned actions run on their own.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const unsubscribe = setupExecutor();
|
|
122
|
+
return unsubscribe;
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
// Default safety gate: confirm sensitive actions, block configured keywords.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const reset = setupSafetyLayer();
|
|
128
|
+
return reset;
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
updateChatScreenContext(screenContext);
|
|
133
|
+
}, [screenContext]);
|
|
134
|
+
|
|
88
135
|
// Stable visitor id for the lifetime of the app install.
|
|
89
136
|
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
137
|
const [contract, setContract] = useState<WebotMeContract | null>(null);
|
|
95
138
|
const [attempt, setAttempt] = useState(0);
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
|
|
140
|
+
// The engine owns the conversation so `chatWithUs` and `useWebotMeChat`
|
|
141
|
+
// always share one session and one transcript.
|
|
142
|
+
const engineState = useChat();
|
|
143
|
+
// Track the live route so the scanner + executor act on the current screen.
|
|
144
|
+
useNavigationAdapter();
|
|
98
145
|
|
|
99
146
|
const chatBaseUrl = useMemo(
|
|
100
147
|
() => (chatBaseUrlProp || apiBaseUrl).replace(/\/+$/, ""),
|
|
@@ -119,79 +166,60 @@ export function WebotMeProvider({
|
|
|
119
166
|
};
|
|
120
167
|
}, [client, attempt]);
|
|
121
168
|
|
|
169
|
+
// Pull the bot's activation/shutdown voice phrases from the server and arm
|
|
170
|
+
// the native voice engine with them ("hello {botName}" wakes it up, the
|
|
171
|
+
// shutdown phrase turns voice off). Mirrors the web widget exactly.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
let active = true;
|
|
174
|
+
client
|
|
175
|
+
.settings()
|
|
176
|
+
.then((raw: any) => {
|
|
177
|
+
if (!active) return;
|
|
178
|
+
const bot = raw?.chatbot ?? raw;
|
|
179
|
+
const botName = typeof bot?.name === "string" && bot.name.trim() ? bot.name.trim() : "Assistant";
|
|
180
|
+
const commands: string[] = Array.isArray(bot?.activation?.commands)
|
|
181
|
+
? bot.activation.commands
|
|
182
|
+
.filter((c: unknown): c is string => typeof c === "string" && c.trim().length > 0)
|
|
183
|
+
.map((c: string) => c.replace(/{botName}/gi, botName).trim())
|
|
184
|
+
: [];
|
|
185
|
+
const activateCommands = commands.filter((c) => !/shutdown|bye|stop|off/i.test(c));
|
|
186
|
+
const shutdownCommands = commands.filter((c) => /shutdown|bye|stop|off/i.test(c));
|
|
187
|
+
if (activateCommands.length > 0 || shutdownCommands.length > 0) {
|
|
188
|
+
configureVoice({ botName, activateCommands, shutdownCommands });
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.catch(() => {
|
|
192
|
+
// Settings unavailable -> voice stays always-on, matching the fallback.
|
|
193
|
+
});
|
|
194
|
+
return () => {
|
|
195
|
+
active = false;
|
|
196
|
+
};
|
|
197
|
+
}, [client]);
|
|
198
|
+
|
|
122
199
|
// New session when the caller bumps resetKey.
|
|
123
200
|
useEffect(() => {
|
|
124
201
|
if (resetKey === undefined) return;
|
|
125
|
-
|
|
126
|
-
setMessages(welcome ? [botMessage(welcome)] : []);
|
|
127
|
-
setError(null);
|
|
128
|
-
setPendingWorkflow(null);
|
|
129
|
-
setAutoTask(null);
|
|
202
|
+
resetChat();
|
|
130
203
|
}, [resetKey, welcome]);
|
|
131
204
|
|
|
132
|
-
const send =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
};
|
|
205
|
+
const send = useCallback(
|
|
206
|
+
async (text: string) => {
|
|
207
|
+
await chatWithUs(text);
|
|
208
|
+
},
|
|
209
|
+
[],
|
|
210
|
+
);
|
|
192
211
|
|
|
193
212
|
const retry = () => setAttempt((n) => n + 1);
|
|
194
|
-
const reset = () =>
|
|
213
|
+
const reset = () => resetChat();
|
|
214
|
+
|
|
215
|
+
const {
|
|
216
|
+
sessionId,
|
|
217
|
+
messages,
|
|
218
|
+
sending,
|
|
219
|
+
error,
|
|
220
|
+
pendingWorkflow,
|
|
221
|
+
autoTask,
|
|
222
|
+
} = engineState;
|
|
195
223
|
|
|
196
224
|
const value = useMemo<WebotMeContextValue>(
|
|
197
225
|
() => ({
|
|
@@ -221,6 +249,9 @@ export function WebotMeProvider({
|
|
|
221
249
|
sending,
|
|
222
250
|
error,
|
|
223
251
|
contract,
|
|
252
|
+
send,
|
|
253
|
+
retry,
|
|
254
|
+
reset,
|
|
224
255
|
pendingWorkflow,
|
|
225
256
|
autoTask,
|
|
226
257
|
],
|
|
@@ -231,8 +262,4 @@ export function WebotMeProvider({
|
|
|
231
262
|
);
|
|
232
263
|
}
|
|
233
264
|
|
|
234
|
-
function botMessage(text: string): WebotMeMessage {
|
|
235
|
-
return { id: makeId("m"), from: "bot", text, createdAt: Date.now() };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
265
|
export type { WebotMeTheme } from "./types";
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing runtime: apps that chat through `chatWithUs` without mounting
|
|
3
|
+
* `<WebotMeProvider>` should still get screen scanning, action execution and
|
|
4
|
+
* the safety gate. Everything here is idempotent, so double-arming (provider +
|
|
5
|
+
* manual call) is harmless.
|
|
6
|
+
*
|
|
7
|
+
* Modules are required lazily on purpose — `runtime` is imported by `chat`,
|
|
8
|
+
* and `executor`/`scanner` import `chat`, so top-level imports would form a
|
|
9
|
+
* cycle at module load time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let executorArmed = false;
|
|
13
|
+
let scannerArmed = false;
|
|
14
|
+
let safetyArmed = false;
|
|
15
|
+
|
|
16
|
+
export function ensureRuntime(): void {
|
|
17
|
+
if (!executorArmed) {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const { setupExecutor } = require("./executor");
|
|
20
|
+
setupExecutor();
|
|
21
|
+
executorArmed = true;
|
|
22
|
+
}
|
|
23
|
+
if (!scannerArmed) {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
|
+
const { setupFiberScanner } = require("./screenScanner");
|
|
26
|
+
setupFiberScanner({ intervalMs: 2500 });
|
|
27
|
+
scannerArmed = true;
|
|
28
|
+
}
|
|
29
|
+
if (!safetyArmed) {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
|
+
const { setupSafetyLayer } = require("./safety");
|
|
32
|
+
setupSafetyLayer();
|
|
33
|
+
safetyArmed = true;
|
|
34
|
+
}
|
|
35
|
+
}
|