@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.
@@ -0,0 +1,376 @@
1
+ import { useEffect, useState } from "react";
2
+ import { updateChatScreenContext } from "./chat";
3
+ import { getCurrentRoute } from "./navigationAdapter";
4
+ import { getCapturedRootFiber } from "./screenHook";
5
+ import type {
6
+ WebotMeField,
7
+ WebotMeButton,
8
+ WebotMeLink,
9
+ WebotMeList,
10
+ WebotMeScreenContext,
11
+ } from "./types";
12
+
13
+ type ReactNodeLike = React.ReactNode;
14
+
15
+ let lastTree: ReactNodeLike = null;
16
+ let scanTimer: ReturnType<typeof setTimeout> | null = null;
17
+ let lastJson = "";
18
+
19
+ /** Provider registers whatever it renders so the scanner can read the tree. */
20
+ export function registerScreenTree(node: ReactNodeLike) {
21
+ lastTree = node;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Fiber-tree helpers (automatic screen capture from the DevTools hook root)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const DEBUG_UI_NAMES = ["ChatBot", "ChatBotIcon", "ChatBotWrapper"];
29
+
30
+ /** Walk live fibers from the captured React root, skipping debug UI that the
31
+ * Chat modal/bar own (otherwise the assistant starts reading its own logs). */
32
+ function walkAllFibers(): any[] {
33
+ const root = getCapturedRootFiber();
34
+ const out: any[] = [];
35
+ if (!root) return out;
36
+
37
+ const stack: any[] = [root];
38
+ let guard = 0;
39
+ while (stack.length && guard < 2000) {
40
+ guard++;
41
+ const fiber = stack.pop();
42
+ const name = fiberTypeName(fiber);
43
+ if (DEBUG_UI_NAMES.includes(name)) {
44
+ // Skip the whole debug/Modal subtree.
45
+ continue;
46
+ }
47
+ out.push(fiber);
48
+ let child = fiber?.child;
49
+ while (child) {
50
+ // Don't even visit the ChatBotIcon/ChatBot children subtree.
51
+ if (!DEBUG_UI_NAMES.includes(fiberTypeName(child))) {
52
+ stack.push(child);
53
+ } else {
54
+ // still push its siblings
55
+ }
56
+ child = child.sibling;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function fiberTypeName(fiber: any): string {
63
+ const t = fiber?.type;
64
+ if (typeof t === "string") return t;
65
+ if (!t) return "";
66
+ if (t.displayName) return t.displayName;
67
+ if (t.name) return t.name;
68
+ if (t.type?.name) return t.type.name;
69
+ if (t.render?.name) return t.render.name;
70
+ if (t.render?.displayName) return t.render.displayName;
71
+ return "";
72
+ }
73
+
74
+ /** Text visible under this fiber (shallow across children). */
75
+ function deepText(fiber: any, depth: number): string {
76
+ if (!fiber || depth > 4) return "";
77
+ const parts: string[] = [];
78
+ const p = fiber.memoizedProps;
79
+ if (p) {
80
+ const kids = Array.isArray(p.children) ? p.children : [p.children];
81
+ for (const k of kids) {
82
+ if (typeof k === "string" && k.trim()) parts.push(k.trim());
83
+ }
84
+ }
85
+ let c = fiber.child;
86
+ while (c) {
87
+ const t = deepText(c, depth + 1);
88
+ if (t) parts.push(t);
89
+ c = c.sibling;
90
+ }
91
+ return parts.join(" ");
92
+ }
93
+
94
+ function normalizeForMatch(v: unknown): string {
95
+ return String(v ?? "").trim().toLowerCase().replace(/[^a-z0-9]/g, "");
96
+ }
97
+
98
+ function textMatches(target: unknown, wanted: unknown): boolean {
99
+ const t = normalizeForMatch(target);
100
+ const w = normalizeForMatch(wanted);
101
+ if (!w) return false;
102
+ return t.includes(w) || w.includes(t);
103
+ }
104
+
105
+ /** Find a pressable component whose visible label matches, return its onPress. */
106
+ export function findClickHandler(label: unknown): (() => any) | null {
107
+ const wanted = normalizeForMatch(label);
108
+ if (!wanted) return null;
109
+ for (const fiber of walkAllFibers()) {
110
+ const p = fiber?.memoizedProps;
111
+ if (!p || typeof p.onPress !== "function") continue;
112
+ const candidates: unknown[] = [
113
+ p.accessibilityLabel,
114
+ p.label,
115
+ p.title,
116
+ p.options?.title,
117
+ deepText(fiber, 0),
118
+ ];
119
+ if (candidates.some((c) => textMatches(c, wanted))) {
120
+ return () => p.onPress();
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /** Find a text input whose label/placeholder/name matches, return a setter. */
127
+ export function findFillHandler(label: unknown): ((value: string) => any) | null {
128
+ const wanted = normalizeForMatch(label);
129
+ if (!wanted) return null;
130
+ for (const fiber of walkAllFibers()) {
131
+ const p = fiber?.memoizedProps;
132
+ if (!p) continue;
133
+ const typeName = fiberTypeName(fiber);
134
+ const isInput =
135
+ /^TextInput|^Input|TextArea/i.test(typeName) ||
136
+ typeof p.onChangeText === "function" ||
137
+ p.placeholder !== undefined ||
138
+ p.keyboardType !== undefined;
139
+ if (!isInput) continue;
140
+ const candidates: unknown[] = [
141
+ p.label,
142
+ p.name,
143
+ p.options?.title,
144
+ p.placeholder,
145
+ p.accessibilityLabel,
146
+ ];
147
+ if (candidates.some((c) => textMatches(c, wanted))) {
148
+ return (value: string) => p.onChangeText?.(value);
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Screen map builder
156
+ // ---------------------------------------------------------------------------
157
+
158
+ function visit(
159
+ node: ReactNodeLike,
160
+ ctx: WebotMeScreenContext,
161
+ depth: number,
162
+ budget: { count: number },
163
+ ): void {
164
+ if (depth > 14 || budget.count > 600) return;
165
+ if (node == null || typeof node === "boolean") return;
166
+
167
+ if (typeof node === "string") {
168
+ const text = node.trim();
169
+ if (text.length > 1 && (ctx.visibleText?.length ?? 0) < 40) {
170
+ ctx.visibleText!.push(text);
171
+ }
172
+ return;
173
+ }
174
+ if (typeof node === "number") return;
175
+
176
+ if (Array.isArray(node)) {
177
+ for (const n of node) visit(n, ctx, depth + 1, budget);
178
+ return;
179
+ }
180
+
181
+ const element = node as { type?: any; props?: Record<string, any> };
182
+ if (typeof element !== "object" || !element.props) return;
183
+
184
+ budget.count++;
185
+ const typeName =
186
+ typeof element.type === "string"
187
+ ? element.type
188
+ : element.type?.displayName || element.type?.name || "";
189
+
190
+ const label =
191
+ element.props.accessibilityLabel ??
192
+ element.props.title ??
193
+ element.props.label ??
194
+ element.props.options?.title ??
195
+ (typeof element.props.children === "string"
196
+ ? element.props.children
197
+ : undefined);
198
+
199
+ const isButton =
200
+ element.props.onPress ||
201
+ /Pressable|Touchable|Button|IconButton/i.test(typeName);
202
+ const isInput =
203
+ element.props.placeholder !== undefined ||
204
+ element.props.keyboardType !== undefined ||
205
+ /TextInput|Input|TextArea/i.test(typeName);
206
+ const isLink =
207
+ /Link/i.test(typeName) ||
208
+ /Screen$/i.test(typeName) ||
209
+ element.props.href ||
210
+ element.props.route;
211
+ const isList =
212
+ /FlatList|SectionList|List$/i.test(typeName) ||
213
+ Array.isArray(element.props.data);
214
+
215
+ if (isButton && label) {
216
+ ctx.buttons!.push({
217
+ text: label,
218
+ accessibilityLabel: element.props.accessibilityLabel,
219
+ disabled: element.props.disabled,
220
+ });
221
+ }
222
+
223
+ if (isInput) {
224
+ ctx.forms!.push({
225
+ label: label ?? element.props.name,
226
+ placeholder: element.props.placeholder,
227
+ name: element.props.name,
228
+ type: element.props.inputMode || element.props.keyboardType,
229
+ value: element.props.value,
230
+ required: element.props.required,
231
+ });
232
+ }
233
+
234
+ if (isLink) {
235
+ ctx.links!.push({
236
+ text: label,
237
+ route: element.props.route ?? element.props.href ?? element.props.name,
238
+ });
239
+ }
240
+
241
+ if (isList) {
242
+ const items = Array.isArray(element.props.data)
243
+ ? element.props.data.map((d: any) => d?.name ?? d?.title ?? String(d))
244
+ : undefined;
245
+ ctx.lists!.push({ label, items });
246
+ }
247
+
248
+ visit(element.props.children, ctx, depth + 1, budget);
249
+ }
250
+
251
+ /** A lightweight map of the current screen for the LLM. Prefers the live
252
+ * fiber tree; falls back to the element props passed to the provider. */
253
+ export function scanScreen(): WebotMeScreenContext {
254
+ const ctx: WebotMeScreenContext = {
255
+ name: getCurrentRoute() || "App",
256
+ visibleText: [],
257
+ buttons: [],
258
+ forms: [],
259
+ links: [],
260
+ lists: [],
261
+ alerts: [],
262
+ };
263
+
264
+ const fibers = walkAllFibers();
265
+ if (fibers.length > 0) {
266
+ for (const fiber of fibers) {
267
+ const p = fiber?.memoizedProps;
268
+ if (!p) continue;
269
+ const name = fiberTypeName(fiber);
270
+
271
+ const isButton =
272
+ typeof p.onPress === "function" ||
273
+ /Pressable|Touchable|^(Icon)?Button$|IconButton/i.test(name);
274
+ const isInput =
275
+ /^TextInput|^Input|TextArea/i.test(name) ||
276
+ typeof p.onChangeText === "function" ||
277
+ p.placeholder !== undefined ||
278
+ p.keyboardType !== undefined;
279
+ const isLink = /Link/i.test(name) || /Screen$/i.test(name) || p.href || p.route;
280
+ const isList = /FlatList|SectionList|List$/i.test(name) || Array.isArray(p.data);
281
+
282
+ const label =
283
+ p.accessibilityLabel ??
284
+ p.label ??
285
+ p.title ??
286
+ p.options?.title ??
287
+ (typeof p.children === "string" ? p.children : undefined);
288
+
289
+ if (isButton && (label || deepText(fiber, 0))) {
290
+ ctx.buttons!.push({
291
+ text: label ?? deepText(fiber, 0),
292
+ accessibilityLabel: p.accessibilityLabel,
293
+ disabled: p.disabled,
294
+ });
295
+ }
296
+ if (isInput) {
297
+ ctx.forms!.push({
298
+ label: label ?? p.name,
299
+ placeholder: p.placeholder,
300
+ name: p.name,
301
+ type: p.inputMode || p.keyboardType,
302
+ value: p.value,
303
+ required: p.required,
304
+ });
305
+ }
306
+ if (isLink) {
307
+ ctx.links!.push({
308
+ text: label ?? p.name,
309
+ route: p.route ?? p.href ?? p.name,
310
+ });
311
+ }
312
+ if (isList) {
313
+ const items = Array.isArray(p.data)
314
+ ? p.data.map((d: any) => d?.name ?? d?.title ?? String(d))
315
+ : undefined;
316
+ ctx.lists!.push({ label: label ?? undefined, items });
317
+ }
318
+
319
+ if (/Text/i.test(name)) {
320
+ const kids = Array.isArray(p.children) ? p.children : [p.children];
321
+ const pieces: string[] = [];
322
+ for (const k of kids) {
323
+ if (typeof k === "string" && k.trim()) pieces.push(k.trim());
324
+ }
325
+ if (pieces.length) ctx.visibleText!.push(pieces.join(" "));
326
+ }
327
+ }
328
+ ctx.visibleText = Array.from(new Set(ctx.visibleText ?? []));
329
+ updateChatScreenContext(ctx);
330
+ return ctx;
331
+ }
332
+
333
+ visit(lastTree, ctx, 0, { count: 0 });
334
+ ctx.visibleText = Array.from(new Set(ctx.visibleText ?? []));
335
+ updateChatScreenContext(ctx);
336
+ return ctx;
337
+ }
338
+
339
+ /**
340
+ * Turn the scanner on. It keeps the chat engine's screenContext fresh.
341
+ * Returns a stop function.
342
+ */
343
+ export function setupFiberScanner(options?: {
344
+ intervalMs?: number;
345
+ onScan?: (ctx: WebotMeScreenContext) => void;
346
+ }): () => void {
347
+ stopFiberScanner();
348
+ const interval = options?.intervalMs ?? 2000;
349
+ scanScreen();
350
+ scanTimer = setInterval(() => {
351
+ const ctx = scanScreen();
352
+ const json = JSON.stringify(ctx);
353
+ if (json !== lastJson) {
354
+ lastJson = json;
355
+ options?.onScan?.(ctx);
356
+ }
357
+ }, interval) as unknown as ReturnType<typeof setTimeout>;
358
+ return stopFiberScanner;
359
+ }
360
+
361
+ export function stopFiberScanner() {
362
+ if (scanTimer) {
363
+ clearTimeout(scanTimer as unknown as ReturnType<typeof setTimeout>);
364
+ scanTimer = null;
365
+ }
366
+ }
367
+
368
+ /** Live snapshot for debug UIs and previews. */
369
+ export function useFiberScanner() {
370
+ const [context, setContext] = useState<WebotMeScreenContext | null>(null);
371
+ useEffect(() => {
372
+ const timer = setInterval(() => setContext(scanScreen()), 2000);
373
+ return () => clearInterval(timer);
374
+ }, []);
375
+ return context;
376
+ }
package/src/types.ts CHANGED
@@ -57,6 +57,8 @@ export type WebotMeMessage = {
57
57
  createdAt: number;
58
58
  /** Present when the server attached a native action (AutoFlow). */
59
59
  action?: WebotMeAction;
60
+ /** Optional list of actions returned for this bot turn. */
61
+ actions?: WebotMeAction[];
60
62
  pending?: boolean;
61
63
  failed?: boolean;
62
64
  };