@webotme/react-native 0.1.1 → 0.1.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 CHANGED
@@ -149,6 +149,81 @@ const { text, action } = await client.send({
149
149
  });
150
150
  ```
151
151
 
152
+ ## Voice
153
+
154
+ `voiceWithUs()` is the voice equivalent of `chatWithUs()` — one call, ask out
155
+ loud, hear the answer:
156
+
157
+ ```tsx
158
+ import { voiceWithUs } from "@webotme/react-native";
159
+
160
+ const res = await voiceWithUs(); // tap the mic
161
+ // user speaks -> device transcribes -> bot replies -> device speaks the reply
162
+ console.log(res.transcript, "->", res.reply);
163
+ ```
164
+
165
+ Just like the web widget, **everything runs on the device**. The phone's own
166
+ recogniser transcribes the question and the phone's own synthesiser speaks the
167
+ answer, so your server only ever sees plain text. No server change, no API keys,
168
+ and no audio ever reaches your backend.
169
+
170
+ ### Install
171
+
172
+ ```bash
173
+ npx expo install expo-speech expo-speech-recognition
174
+ ```
175
+
176
+ Then add the permission strings to `app.json`:
177
+
178
+ ```json
179
+ {
180
+ "expo": {
181
+ "plugins": [
182
+ [
183
+ "expo-speech-recognition",
184
+ {
185
+ "microphonePermission": "Allow Rover to hear you.",
186
+ "speechRecognitionPermission": "Allow Rover to turn your speech into text."
187
+ }
188
+ ]
189
+ ]
190
+ }
191
+ }
192
+ ```
193
+
194
+ Both packages are **optional peer dependencies**, resolved on first use. An app
195
+ that only sends text never loads them and never breaks. Speech recognition is
196
+ native code, so it needs a development build — `npx expo run:android` or
197
+ `npx expo run:ios` — not Expo Go. `expo-speech` alone also needs a rebuild.
198
+
199
+ ### Options
200
+
201
+ ```tsx
202
+ configureVoice({
203
+ lang: "en-US", // BCP-47, used for both listening and speaking
204
+ rate: 0.9, // web widget uses the same friendly pace
205
+ speakAllReplies: false, // true = read every reply, not just spoken ones
206
+ onPartial: (text) => setText(text), // live transcript while talking
207
+ onUserQuery: (text) => console.log(text),
208
+ onError: (msg) => alert(msg),
209
+ });
210
+ ```
211
+
212
+ ### UI state
213
+
214
+ `useVoiceChat()` mirrors `useChat()`:
215
+
216
+ ```tsx
217
+ import { useVoiceChat } from "@webotme/react-native";
218
+
219
+ const { state, transcript, listening, speaking, start, stop } = useVoiceChat();
220
+ // state: "idle" | "listening" | "thinking" | "speaking" | "unsupported" | "denied"
221
+ ```
222
+
223
+ Other helpers: `speakWithUs(text)` to read any line aloud, `stopVoice()` to stop
224
+ listening and talking, `setVoiceReplies(on)` to toggle replies, and
225
+ `isVoiceSupported()` to feature-detect.
226
+
152
227
  ## Errors
153
228
 
154
229
  Every failure is a `WebotMeError` with a stable `code`:
@@ -184,6 +259,10 @@ your own backend and use `POST /api/sdk/chat`.
184
259
  | New session | `POST /api/chatbot/public/{botId}/session/new` |
185
260
  | Web chat page | `GET {chatBaseUrl}/chat/{botId}` |
186
261
 
262
+ Voice uses **none** of these. `voiceWithUs()` transcribes and speaks on the
263
+ device, so `/native-chat/v1/stt` and `/native-chat/v1/tts` are never called —
264
+ the same way the web widget runs on the browser's own speech APIs.
265
+
187
266
  ## License
188
267
 
189
268
  UNLICENSED
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webotme/react-native",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Drop-in AI chatbot for React Native and Expo apps. Same bot, training and AutoFlow as the WebotMe web widget.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -11,12 +11,18 @@
11
11
  "scripts": {
12
12
  "build": "tsc --noEmit",
13
13
  "prepublishOnly": "npm run build",
14
- "typecheck": "tsc --noEmit"
14
+ "typecheck": "tsc --noEmit",
15
+ "test:voice": "tsc -p tsconfig.smoke.json && node .smoke-build/voice.smoke.js && node .smoke-build/voice-nomodules.smoke.js",
16
+ "test": "npm run typecheck"
15
17
  },
16
18
  "keywords": [
17
19
  "chatbot",
18
20
  "ai",
19
21
  "chat",
22
+ "voice",
23
+ "speech",
24
+ "text-to-speech",
25
+ "speech-recognition",
20
26
  "react-native",
21
27
  "expo",
22
28
  "webotme",
@@ -24,11 +30,19 @@
24
30
  ],
25
31
  "license": "UNLICENSED",
26
32
  "peerDependencies": {
33
+ "expo-speech": ">=13",
34
+ "expo-speech-recognition": ">=3",
27
35
  "react": ">=18",
28
36
  "react-native": ">=0.72",
29
37
  "react-native-webview": ">=13"
30
38
  },
31
39
  "peerDependenciesMeta": {
40
+ "expo-speech": {
41
+ "optional": true
42
+ },
43
+ "expo-speech-recognition": {
44
+ "optional": true
45
+ },
32
46
  "react-native-webview": {
33
47
  "optional": true
34
48
  }
@@ -39,8 +53,5 @@
39
53
  "react-native": "^0.76.9",
40
54
  "react-native-webview": "^13.17.0",
41
55
  "typescript": "^5.9.3"
42
- },
43
- "dependencies": {
44
- "expo-av": "^16.0.8"
45
56
  }
46
57
  }
package/src/chat.ts ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * One-function chat for apps that already have their own UI.
3
+ *
4
+ * The provider owns the session when it is mounted, so `chatWithUs` works with
5
+ * no extra setup. Apps that skip the provider can call `configureChat` once.
6
+ */
7
+ import { useEffect, useState } from "react";
8
+ import { WebotMeClient } from "./client";
9
+ import {
10
+ WebotMeError,
11
+ type WebotMeAction,
12
+ type WebotMeAutoTask,
13
+ type WebotMeConfig,
14
+ type WebotMeMessage,
15
+ type WebotMePendingWorkflow,
16
+ type WebotMeScreenContext,
17
+ } from "./types";
18
+
19
+ const makeId = (prefix: string) =>
20
+ `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
21
+
22
+ type Listener = () => void;
23
+
24
+ type ChatState = {
25
+ sessionId: string;
26
+ messages: WebotMeMessage[];
27
+ sending: boolean;
28
+ error: string | null;
29
+ pendingWorkflow: WebotMePendingWorkflow | null;
30
+ autoTask: WebotMeAutoTask | null;
31
+ };
32
+
33
+ /** What `chatWithUs` hands back so a custom UI can react without a hook. */
34
+ export type ChatResult = {
35
+ ok: boolean;
36
+ /** The bot's reply, or an empty string when the send failed. */
37
+ reply: string;
38
+ /** The user's message plus the bot's reply, ready to render. */
39
+ userMessage: WebotMeMessage | null;
40
+ botMessage: WebotMeMessage | null;
41
+ action?: WebotMeAction;
42
+ actions?: WebotMeAction[];
43
+ pendingWorkflow?: WebotMePendingWorkflow;
44
+ autoTask?: WebotMeAutoTask;
45
+ error?: string;
46
+ };
47
+
48
+ class ChatEngine {
49
+ private client: WebotMeClient | null = null;
50
+ private userId = "";
51
+ private sessionId = makeId("s");
52
+ private screenContext: WebotMeScreenContext | undefined;
53
+ private listeners = new Set<Listener>();
54
+ private inflight = false;
55
+ private state: ChatState;
56
+
57
+ constructor() {
58
+ this.state = {
59
+ sessionId: this.sessionId,
60
+ messages: [],
61
+ sending: false,
62
+ error: null,
63
+ pendingWorkflow: null,
64
+ autoTask: null,
65
+ };
66
+ }
67
+
68
+ configure(config: WebotMeConfig) {
69
+ const nextClient = new WebotMeClient({
70
+ botId: config.botId,
71
+ apiBaseUrl: config.apiBaseUrl,
72
+ timeoutMs: config.timeoutMs,
73
+ });
74
+ const changedBot = !this.client || this.client.botId !== config.botId;
75
+ this.client = nextClient;
76
+ if (config.userId) this.userId = config.userId;
77
+ if (!this.userId) this.userId = makeId("v");
78
+ this.screenContext = config.screenContext;
79
+ if (changedBot) this.reset();
80
+ if (config.welcome && this.state.messages.length === 0) {
81
+ this.patch({
82
+ messages: [
83
+ { id: makeId("m"), from: "bot", text: config.welcome, createdAt: Date.now() },
84
+ ],
85
+ });
86
+ } else {
87
+ this.emit();
88
+ }
89
+ }
90
+
91
+ /** Keeps the live screen context in sync without re-creating the client. */
92
+ updateScreenContext(screenContext: WebotMeScreenContext | undefined) {
93
+ this.screenContext = screenContext;
94
+ }
95
+
96
+ private patch(next: Partial<ChatState>) {
97
+ this.state = { ...this.state, ...next };
98
+ this.emit();
99
+ }
100
+
101
+ private emit() {
102
+ for (const listener of this.listeners) listener();
103
+ }
104
+
105
+ subscribe(listener: Listener) {
106
+ this.listeners.add(listener);
107
+ return () => {
108
+ this.listeners.delete(listener);
109
+ };
110
+ }
111
+
112
+ getState(): ChatState {
113
+ return this.state;
114
+ }
115
+
116
+ reset() {
117
+ this.sessionId = makeId("s");
118
+ this.patch({
119
+ sessionId: this.sessionId,
120
+ messages: [],
121
+ sending: false,
122
+ error: null,
123
+ pendingWorkflow: null,
124
+ autoTask: null,
125
+ });
126
+ }
127
+
128
+ isReady() {
129
+ return this.client !== null;
130
+ }
131
+
132
+ getSessionId() {
133
+ return this.sessionId;
134
+ }
135
+
136
+ async send(text: string): Promise<ChatResult> {
137
+ const trimmed = text.trim();
138
+ if (!trimmed) {
139
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error: "Message is empty" };
140
+ }
141
+ if (!this.client) {
142
+ const error = "Chat is not configured. Wrap the app in <WebotMeProvider> or call configureChat() first.";
143
+ this.patch({ error });
144
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
145
+ }
146
+ if (this.inflight) {
147
+ const error = "A message is already being sent.";
148
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
149
+ }
150
+
151
+ const client = this.client;
152
+ const sessionId = this.sessionId;
153
+ const userId = this.userId;
154
+
155
+ const userMessage: WebotMeMessage = {
156
+ id: makeId("m"),
157
+ from: "user",
158
+ text: trimmed,
159
+ createdAt: Date.now(),
160
+ };
161
+ const pendingId = makeId("m");
162
+
163
+ this.inflight = true;
164
+ this.patch({
165
+ messages: [
166
+ ...this.state.messages,
167
+ userMessage,
168
+ { id: pendingId, from: "bot", text: "", createdAt: Date.now(), pending: true },
169
+ ],
170
+ sending: true,
171
+ error: null,
172
+ });
173
+
174
+ try {
175
+ const result = await client.send({
176
+ message: trimmed,
177
+ sessionId,
178
+ userId,
179
+ screenContext: this.screenContext,
180
+ autoTask: this.state.autoTask ?? undefined,
181
+ });
182
+
183
+ const botMessage: WebotMeMessage = {
184
+ id: pendingId,
185
+ from: "bot",
186
+ text: result.text || result.reply || "",
187
+ createdAt: Date.now(),
188
+ action: result.actions?.[0] ?? result.action,
189
+ actions: result.actions,
190
+ };
191
+
192
+ this.patch({
193
+ messages: this.state.messages.map((m) => (m.id === pendingId ? botMessage : m)),
194
+ sending: false,
195
+ error: null,
196
+ pendingWorkflow: result.pendingWorkflow ?? null,
197
+ autoTask: result.autoTask ?? null,
198
+ });
199
+
200
+ return {
201
+ ok: true,
202
+ reply: botMessage.text,
203
+ userMessage,
204
+ botMessage,
205
+ action: botMessage.action,
206
+ actions: result.actions,
207
+ pendingWorkflow: result.pendingWorkflow,
208
+ autoTask: result.autoTask,
209
+ };
210
+ } catch (err) {
211
+ const error =
212
+ err instanceof WebotMeError || err instanceof Error
213
+ ? err.message
214
+ : "Something went wrong";
215
+ this.patch({
216
+ messages: this.state.messages.filter((m) => m.id !== pendingId),
217
+ sending: false,
218
+ error,
219
+ });
220
+ return { ok: false, reply: "", userMessage, botMessage: null, error };
221
+ } finally {
222
+ this.inflight = false;
223
+ }
224
+ }
225
+
226
+ /** Push the current live screen snapshot to the autonomous AutoFlow loop. */
227
+ async sendStep(): Promise<ChatResult> {
228
+ if (!this.client) {
229
+ const error = "Chat is not configured. Wrap the app in <WebotMeProvider> or call configureChat() first.";
230
+ this.patch({ error });
231
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
232
+ }
233
+ if (!this.state.autoTask || !this.state.autoTask.goal) {
234
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error: "No AutoFlow task to advance." };
235
+ }
236
+ if (this.inflight) {
237
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error: "A step is already in flight." };
238
+ }
239
+ const pendingId = makeId("m");
240
+ this.inflight = true;
241
+ this.patch({ sending: true, error: null });
242
+ try {
243
+ const result = await this.client.send({
244
+ message: "",
245
+ sessionId: this.sessionId,
246
+ userId: this.userId,
247
+ screenContext: this.screenContext,
248
+ autoTask: this.state.autoTask as any,
249
+ });
250
+ const botMessage: WebotMeMessage = {
251
+ id: pendingId,
252
+ from: "bot",
253
+ text: result.text || result.reply || "",
254
+ createdAt: Date.now(),
255
+ action: result.actions?.[0] ?? result.action,
256
+ actions: result.actions,
257
+ };
258
+ this.patch({
259
+ messages: [
260
+ ...this.state.messages,
261
+ botMessage,
262
+ ],
263
+ sending: false,
264
+ error: null,
265
+ pendingWorkflow: result.pendingWorkflow ?? null,
266
+ autoTask: result.autoTask ?? this.state.autoTask,
267
+ });
268
+ return {
269
+ ok: true,
270
+ reply: botMessage.text,
271
+ userMessage: null,
272
+ botMessage,
273
+ action: botMessage.action,
274
+ actions: result.actions,
275
+ pendingWorkflow: result.pendingWorkflow,
276
+ autoTask: result.autoTask,
277
+ };
278
+ } catch (err) {
279
+ const error =
280
+ err instanceof WebotMeError || err instanceof Error ? err.message : "Something went wrong";
281
+ this.patch({ sending: false, error });
282
+ return { ok: false, reply: "", userMessage: null, botMessage: null, error };
283
+ } finally {
284
+ this.inflight = false;
285
+ }
286
+ }
287
+ }
288
+
289
+ const engine = new ChatEngine();
290
+
291
+ /**
292
+ * Point the one-function chat at a bot. Only needed when the app does not use
293
+ * `WebotMeProvider`, which configures the engine automatically.
294
+ */
295
+ export function configureChat(config: WebotMeConfig) {
296
+ engine.configure(config);
297
+ }
298
+
299
+ /**
300
+ * Keeps `chatWithUs` sending an up-to-date screen snapshot. The provider calls
301
+ * this for you; only touch it when driving the engine manually.
302
+ */
303
+ export function updateChatScreenContext(screenContext: WebotMeScreenContext | undefined) {
304
+ engine.updateScreenContext(screenContext);
305
+ }
306
+
307
+ /** The id the next message will be sent under. */
308
+ export function getChatSessionId() {
309
+ return engine.getSessionId();
310
+ }
311
+
312
+ /** Subscribe to chat engine state (used by executor/observer utilities). */
313
+ export function subscribeChat(listener: () => void): () => void {
314
+ return engine.subscribe(listener);
315
+ }
316
+
317
+ /** Current chat state snapshot. */
318
+ export function getChatState() {
319
+ return engine.getState();
320
+ }
321
+
322
+ /**
323
+ * Send the next AutoFlow step (empty message) to advance the server's autonomous
324
+ * task loop. Provider/executor wires this up internally.
325
+ */
326
+ export async function nextAutoFlowStep(): Promise<ChatResult> {
327
+ return engine.sendStep();
328
+ }
329
+
330
+ /**
331
+ * Send one message to the bot and wait for its reply.
332
+ *
333
+ * ```tsx
334
+ * <TouchableOpacity
335
+ * onPress={async () => {
336
+ * const res = await chatWithUs(text);
337
+ * if (res.ok) setText("");
338
+ * }}
339
+ * />
340
+ * ```
341
+ */
342
+ export async function chatWithUs(text: string): Promise<ChatResult> {
343
+ return engine.send(text);
344
+ }
345
+
346
+ /** Clear the conversation and start a fresh session. */
347
+ export function resetChat() {
348
+ engine.reset();
349
+ }
350
+
351
+ /**
352
+ * Live chat state for rendering. Optional: use it to show the transcript, and
353
+ * keep `chatWithUs` for sending.
354
+ */
355
+ export function useChat() {
356
+ const [state, setState] = useState<ChatState>(() => engine.getState());
357
+
358
+ useEffect(() => {
359
+ setState(engine.getState());
360
+ return engine.subscribe(() => setState(engine.getState()));
361
+ }, []);
362
+
363
+ return {
364
+ ...state,
365
+ send: chatWithUs,
366
+ reset: resetChat,
367
+ ready: engine.isReady(),
368
+ };
369
+ }
package/src/client.ts CHANGED
@@ -31,7 +31,8 @@ export type SendResult = {
31
31
  */
32
32
  export class WebotMeClient {
33
33
  private apiBaseUrl: string;
34
- private botId: string;
34
+ /** Public so a host app can tell which bot an instance is bound to. */
35
+ readonly botId: string;
35
36
  private timeoutMs: number;
36
37
 
37
38
  constructor(config: Pick<WebotMeConfig, "botId" | "apiBaseUrl" | "timeoutMs">) {
@@ -112,6 +113,7 @@ export class WebotMeClient {
112
113
  sessionId: string;
113
114
  userId: string;
114
115
  screenContext?: WebotMeScreenContext;
116
+ autoTask?: WebotMeAutoTask;
115
117
  }): Promise<SendResult> {
116
118
  const body = await this.request<Record<string, unknown>>("/native-chat/v1", {
117
119
  method: "POST",
@@ -120,6 +122,7 @@ export class WebotMeClient {
120
122
  sessionId: params.sessionId,
121
123
  userId: params.userId,
122
124
  ...(params.screenContext ? { screenContext: params.screenContext } : {}),
125
+ ...(params.autoTask ? { autoTask: params.autoTask } : {}),
123
126
  }),
124
127
  });
125
128
 
@@ -0,0 +1,156 @@
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 type { WebotMeAction, WebotMeMessage } from "./types";
6
+
7
+ export type ExecutorOptions = {
8
+ /** Called to navigate; defaults to expo-router when available. */
9
+ navigate?: (routeName: string, params?: Record<string, any>) => void;
10
+ goBack?: () => void;
11
+ push?: (routeName: string, params?: Record<string, any>) => void;
12
+ /** Called after every action attempt. */
13
+ onActionExecuted?: (action: WebotMeAction, success: boolean, error?: string) => void;
14
+ };
15
+
16
+ let handlers: ExecutorOptions | null = null;
17
+ const executedMessageIds = new Set<string>();
18
+
19
+ function routeNameFromUrl(url: string): string {
20
+ const match = /^([^?#]+)/.exec(url)?.[1] ?? url;
21
+ const trimmed = match.replace(/^\/+|\/+$/g, "");
22
+ if (!trimmed) return "Home";
23
+ const parts = trimmed.split("/").map((part) =>
24
+ part.charAt(0).toUpperCase() + part.slice(1),
25
+ );
26
+ return parts.join(":");
27
+ }
28
+
29
+ function defaultNavigate(routeName: string, params?: Record<string, any>) {
30
+ try {
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const router = require("expo-router").default ?? require("expo-router").router;
33
+ router?.push?.(routeName as any);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** Run a single server action on the device. */
41
+ export async function executeAction(action: WebotMeAction): Promise<{ success: boolean; error?: string }> {
42
+ const doNavigate = handlers?.navigate;
43
+ try {
44
+ // Block/confirm at the gate before doing anything on the device.
45
+ const allowed = await isActionAllowed(action);
46
+ if (!allowed) return { success: false, error: "Blocked or declined by safety layer" };
47
+
48
+ switch (action.type) {
49
+ case "navigate": {
50
+ const url = (action.data as any)?.url;
51
+ if (!url) throw new Error("Navigate action missing URL");
52
+ const name = routeNameFromUrl(url);
53
+ if (doNavigate) {
54
+ doNavigate(name, { fromAutoFlow: true, originalUrl: url });
55
+ return { success: true };
56
+ }
57
+ if (handlers?.push) {
58
+ handlers.push(name, { fromAutoFlow: true, originalUrl: url });
59
+ return { success: true };
60
+ }
61
+ const ok = defaultNavigate(url);
62
+ if (!ok) throw new Error(`No navigation handler for "${url}"`);
63
+ return { success: true };
64
+ }
65
+ case "click": {
66
+ const label =
67
+ (action.data as any)?.text ??
68
+ (action.data as any)?.selector ??
69
+ (action.data as any)?.label;
70
+ const h = findClickHandler(label);
71
+ if (h) {
72
+ h();
73
+ return { success: true };
74
+ }
75
+ console.warn("[WebotMe executor] click action has no ref binding:", action.data);
76
+ return { success: true };
77
+ }
78
+ case "fill": {
79
+ const label =
80
+ (action.data as any)?.label ??
81
+ (action.data as any)?.selector ??
82
+ (action.data as any)?.name;
83
+ const value = (action.data as any)?.text;
84
+ const setter = findFillHandler(label);
85
+ if (setter) {
86
+ setter(typeof value === "string" ? value : String(value ?? ""));
87
+ return { success: true };
88
+ }
89
+ console.log(
90
+ `[WebotMe executor] fill "${label}" -> "${value}" -- no matching input found.`,
91
+ );
92
+ return { success: true };
93
+ }
94
+ case "checkout": {
95
+ const url = (action.data as any)?.url;
96
+ if (!url) throw new Error("Checkout action missing URL");
97
+ await Linking.openURL(url);
98
+ return { success: true };
99
+ }
100
+ default:
101
+ return { success: false, error: `Unknown action type: ${(action as any).type}` };
102
+ }
103
+ } catch (err) {
104
+ return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
105
+ }
106
+ }
107
+
108
+ /** Run the actions a bot returns on its message, in order. */
109
+ export async function executeActions(actions: WebotMeAction[]): Promise<void> {
110
+ for (const action of actions.slice(0, 20)) {
111
+ const result = await executeAction(action);
112
+ handlers?.onActionExecuted?.(action, result.success, result.error);
113
+ if (!result.success) {
114
+ Alert.alert("Action Failed", result.error || "Unknown error");
115
+ break;
116
+ }
117
+ await new Promise((r) => setTimeout(r, 300));
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Arm the executor: it watches the shared chat engine and runs each bot action
123
+ * exactly once. Returns a disposer.
124
+ */
125
+ export function setupExecutor(options?: ExecutorOptions): () => void {
126
+ handlers = options ?? handlers ?? {};
127
+ const unsubscribe = subscribeChat(() => {
128
+ const { messages } = getChatState();
129
+ for (const message of messages) {
130
+ if (!message || message.from !== "bot" || executedMessageIds.has(message.id)) continue;
131
+ const actions = message.actions && message.actions.length > 0
132
+ ? message.actions
133
+ : message.action
134
+ ? [message.action]
135
+ : [];
136
+ if (actions.length === 0) continue;
137
+ executedMessageIds.add(message.id);
138
+ void executeActions(actions).then(() => {
139
+ // If we're inside an AutoFlow task, let the server decide the next step.
140
+ const state = getChatState();
141
+ if (state.autoTask && state.autoTask.goal) {
142
+ // Wait for the screen to settle so the next step sees fresh screenContext.
143
+ setTimeout(() => {
144
+ void nextAutoFlowStep();
145
+ }, 2500);
146
+ }
147
+ });
148
+ }
149
+ });
150
+ return () => unsubscribe();
151
+ }
152
+
153
+ export function resetExecutorState() {
154
+ executedMessageIds.clear();
155
+ handlers = null;
156
+ }