@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/safety.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Alert } from "react-native";
|
|
2
|
+
import type { WebotMeAction } from "./types";
|
|
3
|
+
|
|
4
|
+
type SafetyOptions = {
|
|
5
|
+
/** Show a confirmation before running any action. Default false. */
|
|
6
|
+
alwaysConfirm?: boolean;
|
|
7
|
+
/** URL/label substrings that make an action require confirmation. */
|
|
8
|
+
confirmKeywords?: string[];
|
|
9
|
+
/** URL/label substrings whose actions are blocked entirely. */
|
|
10
|
+
blockKeywords?: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let options: SafetyOptions = {
|
|
14
|
+
alwaysConfirm: false,
|
|
15
|
+
confirmKeywords: ["delete", "remove", "cancel", "purchase", "checkout", "payment", "order", "logout"],
|
|
16
|
+
blockKeywords: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function setupSafetyLayer(next?: Partial<SafetyOptions>): () => void {
|
|
20
|
+
const previous = options;
|
|
21
|
+
options = { ...options, ...next };
|
|
22
|
+
return () => {
|
|
23
|
+
options = previous;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hazardText(action: WebotMeAction): string {
|
|
28
|
+
const parts = [
|
|
29
|
+
action.type,
|
|
30
|
+
(action as any).label ?? "",
|
|
31
|
+
(action as any).data?.url ?? "",
|
|
32
|
+
(action as any).data?.text ?? "",
|
|
33
|
+
(action as any).data?.label ?? "",
|
|
34
|
+
(action as any).data?.selector ?? "",
|
|
35
|
+
];
|
|
36
|
+
return parts.join(" ").toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isBlocked(action: WebotMeAction): boolean {
|
|
40
|
+
const hay = hazardText(action);
|
|
41
|
+
return (options.blockKeywords ?? []).some((k) => k && hay.includes(k.toLowerCase()));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function needsConfirmation(action: WebotMeAction): boolean {
|
|
45
|
+
if (options.alwaysConfirm) return true;
|
|
46
|
+
if (action.type === "checkout") return true;
|
|
47
|
+
const hay = hazardText(action);
|
|
48
|
+
return (options.confirmKeywords ?? []).some((k) => k && hay.includes(k.toLowerCase()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Ask the user before running an action. Returns true if approved. */
|
|
52
|
+
export function requireConfirmation(action: WebotMeAction): Promise<boolean> {
|
|
53
|
+
const label = (action as any).label ?? (action as any).data?.url ?? (action as any).data?.text ?? action.type;
|
|
54
|
+
return new Promise<boolean>((resolve) => {
|
|
55
|
+
Alert.alert(
|
|
56
|
+
"Allow this action?",
|
|
57
|
+
`${action.type}: ${label}`,
|
|
58
|
+
[
|
|
59
|
+
{ text: "Cancel", style: "cancel", onPress: () => resolve(false) },
|
|
60
|
+
{ text: "Allow", onPress: () => resolve(true) },
|
|
61
|
+
],
|
|
62
|
+
{ cancelable: true, onDismiss: () => resolve(false) },
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Full gate used by the executor before every action run. */
|
|
68
|
+
export async function isActionAllowed(action: WebotMeAction): Promise<boolean> {
|
|
69
|
+
if (isBlocked(action)) return false;
|
|
70
|
+
if (needsConfirmation(action)) return requireConfirmation(action);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
package/src/scan.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
��
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen hook: grab the live React tree by installing a minimal React
|
|
3
|
+
* DevTools global hook (the same seam the React DevTools overlay uses)
|
|
4
|
+
* *before* React / RN get to wire themselves in. An app that wants the
|
|
5
|
+
* full automatic screen scan should require this module as the very first
|
|
6
|
+
* import — typically via a tiny `entry.js` that replaces the package `main`.
|
|
7
|
+
*
|
|
8
|
+
* The package's own entry point can't do this alone: by the time
|
|
9
|
+
* `<WebotMeProvider>` renders, RN's ReactFabric module has already decided
|
|
10
|
+
* not to call into the hook. A file loaded *before* expo-router/entry can.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let capturedRoot: any = null;
|
|
14
|
+
|
|
15
|
+
function capture(root: any) {
|
|
16
|
+
try {
|
|
17
|
+
capturedRoot = root || null;
|
|
18
|
+
} catch {
|
|
19
|
+
/* noop */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installScreenHook(): void {
|
|
24
|
+
const g = globalThis as any;
|
|
25
|
+
const existing = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
26
|
+
|
|
27
|
+
if (existing) {
|
|
28
|
+
// DevTools attached already: wrap commits so we capture the root too.
|
|
29
|
+
if (!existing.__weBotCapturedHook) {
|
|
30
|
+
const prev = existing.onCommitFiberRoot;
|
|
31
|
+
existing.onCommitFiberRoot = (id: any, root: any, ...rest: any[]) => {
|
|
32
|
+
capture(root);
|
|
33
|
+
try {
|
|
34
|
+
prev?.(id, root, ...rest);
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
existing.__weBotCapturedHook = true;
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hook: any = {
|
|
45
|
+
isDisabled: false,
|
|
46
|
+
supportsFiber: true,
|
|
47
|
+
checkDCE: false,
|
|
48
|
+
renderers: new Map(),
|
|
49
|
+
internalModuleStop: undefined,
|
|
50
|
+
getFiberRoots(_id: any) {
|
|
51
|
+
return capturedRoot ? new Set([capturedRoot]) : new Set();
|
|
52
|
+
},
|
|
53
|
+
inject(internals: any) {
|
|
54
|
+
hook.injectedInternals = internals;
|
|
55
|
+
return 1;
|
|
56
|
+
},
|
|
57
|
+
onScheduleFiberRoot() {},
|
|
58
|
+
onCommitFiberRoot(_id: any, root: any) {
|
|
59
|
+
capture(root);
|
|
60
|
+
},
|
|
61
|
+
onCommitFiberUnmount() {},
|
|
62
|
+
onPostCommitFiberRoot() {},
|
|
63
|
+
setStrictMode() {},
|
|
64
|
+
registerInternalModuleStop() {},
|
|
65
|
+
overrideHookState() {},
|
|
66
|
+
supportsChunkDevMode: false,
|
|
67
|
+
};
|
|
68
|
+
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
installScreenHook();
|
|
72
|
+
|
|
73
|
+
/** The committed React fiber root (HostRoot) of the app, or null if not yet. */
|
|
74
|
+
export function getCapturedRootFiber(): any | null {
|
|
75
|
+
try {
|
|
76
|
+
const raw = capturedRoot;
|
|
77
|
+
if (!raw) return null;
|
|
78
|
+
return raw.current ? raw.current : raw;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
const interval = options?.intervalMs ?? 2000;
|
|
348
|
+
// Idempotent: if a scanner is already running, just refresh the snapshot and
|
|
349
|
+
// keep the single timer (avoids duplicate intervals when the provider arms
|
|
350
|
+
// after an app already called chatWithUs/configureChat).
|
|
351
|
+
if (scanTimer) {
|
|
352
|
+
scanScreen();
|
|
353
|
+
return stopFiberScanner;
|
|
354
|
+
}
|
|
355
|
+
scanScreen();
|
|
356
|
+
scanTimer = setInterval(() => {
|
|
357
|
+
const ctx = scanScreen();
|
|
358
|
+
const json = JSON.stringify(ctx);
|
|
359
|
+
if (json !== lastJson) {
|
|
360
|
+
lastJson = json;
|
|
361
|
+
options?.onScan?.(ctx);
|
|
362
|
+
}
|
|
363
|
+
}, interval) as unknown as ReturnType<typeof setTimeout>;
|
|
364
|
+
return stopFiberScanner;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function stopFiberScanner() {
|
|
368
|
+
if (scanTimer) {
|
|
369
|
+
clearTimeout(scanTimer as unknown as ReturnType<typeof setTimeout>);
|
|
370
|
+
scanTimer = null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Live snapshot for debug UIs and previews. */
|
|
375
|
+
export function useFiberScanner() {
|
|
376
|
+
const [context, setContext] = useState<WebotMeScreenContext | null>(null);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const timer = setInterval(() => setContext(scanScreen()), 2000);
|
|
379
|
+
return () => clearInterval(timer);
|
|
380
|
+
}, []);
|
|
381
|
+
return context;
|
|
382
|
+
}
|
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
|
};
|