@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.
@@ -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
- const [pendingWorkflow, setPendingWorkflow] = useState<WebotMePendingWorkflow | null>(null);
97
- const [autoTask, setAutoTask] = useState<WebotMeAutoTask | null>(null);
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
- setSessionId(makeId("s"));
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 = 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
- };
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 = () => setSessionId(makeId("s"));
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
+ }