react-native-agentic-ai 0.0.2 → 0.2.0
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/LICENSE +20 -0
- package/README.md +253 -14
- package/lib/module/components/AIAgent.js +185 -0
- package/lib/module/components/AIAgent.js.map +1 -0
- package/lib/module/components/AgentChatBar.js +268 -0
- package/lib/module/components/AgentChatBar.js.map +1 -0
- package/lib/module/components/AgentOverlay.js +53 -0
- package/lib/module/components/AgentOverlay.js.map +1 -0
- package/lib/module/core/AgentRuntime.js +640 -0
- package/lib/module/core/AgentRuntime.js.map +1 -0
- package/lib/module/core/FiberTreeWalker.js +362 -0
- package/lib/module/core/FiberTreeWalker.js.map +1 -0
- package/lib/module/core/MCPBridge.js +98 -0
- package/lib/module/core/MCPBridge.js.map +1 -0
- package/lib/module/core/ScreenDehydrator.js +46 -0
- package/lib/module/core/ScreenDehydrator.js.map +1 -0
- package/lib/module/core/systemPrompt.js +164 -0
- package/lib/module/core/systemPrompt.js.map +1 -0
- package/lib/module/core/types.js +2 -0
- package/lib/module/core/types.js.map +1 -0
- package/lib/module/hooks/useAction.js +32 -0
- package/lib/module/hooks/useAction.js.map +1 -0
- package/lib/module/index.js +17 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/providers/GeminiProvider.js +294 -0
- package/lib/module/providers/GeminiProvider.js.map +1 -0
- package/lib/module/utils/logger.js +17 -0
- package/lib/module/utils/logger.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/components/AIAgent.d.ts +65 -0
- package/lib/typescript/src/components/AIAgent.d.ts.map +1 -0
- package/lib/typescript/src/components/AgentChatBar.d.ts +15 -0
- package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -0
- package/lib/typescript/src/components/AgentOverlay.d.ts +10 -0
- package/lib/typescript/src/components/AgentOverlay.d.ts.map +1 -0
- package/lib/typescript/src/core/AgentRuntime.d.ts +53 -0
- package/lib/typescript/src/core/AgentRuntime.d.ts.map +1 -0
- package/lib/typescript/src/core/FiberTreeWalker.d.ts +31 -0
- package/lib/typescript/src/core/FiberTreeWalker.d.ts.map +1 -0
- package/lib/typescript/src/core/MCPBridge.d.ts +23 -0
- package/lib/typescript/src/core/MCPBridge.d.ts.map +1 -0
- package/lib/typescript/src/core/ScreenDehydrator.d.ts +20 -0
- package/lib/typescript/src/core/ScreenDehydrator.d.ts.map +1 -0
- package/lib/typescript/src/core/systemPrompt.d.ts +9 -0
- package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -0
- package/lib/typescript/src/core/types.d.ts +176 -0
- package/lib/typescript/src/core/types.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useAction.d.ts +13 -0
- package/lib/typescript/src/hooks/useAction.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +10 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/providers/GeminiProvider.d.ts +43 -0
- package/lib/typescript/src/providers/GeminiProvider.d.ts.map +1 -0
- package/lib/typescript/src/utils/logger.d.ts +7 -0
- package/lib/typescript/src/utils/logger.d.ts.map +1 -0
- package/package.json +135 -12
- package/src/components/AIAgent.tsx +262 -0
- package/src/components/AgentChatBar.tsx +258 -0
- package/src/components/AgentOverlay.tsx +48 -0
- package/src/core/AgentRuntime.ts +661 -0
- package/src/core/FiberTreeWalker.ts +404 -0
- package/src/core/MCPBridge.ts +110 -0
- package/src/core/ScreenDehydrator.ts +53 -0
- package/src/core/systemPrompt.ts +162 -0
- package/src/core/types.ts +233 -0
- package/src/hooks/useAction.ts +40 -0
- package/src/index.ts +22 -0
- package/src/providers/GeminiProvider.ts +283 -0
- package/src/utils/logger.ts +21 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FiberTreeWalker — Traverses React's Fiber tree to discover interactive elements.
|
|
3
|
+
*
|
|
4
|
+
* This is the React Native equivalent of page-agent.js reading the DOM.
|
|
5
|
+
* Instead of traversing HTML nodes, we traverse React Fiber nodes and detect
|
|
6
|
+
* interactive elements by their type and props (onPress, onChangeText, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Architecture inspired by: https://github.com/alibaba/page-agent
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from '../utils/logger';
|
|
12
|
+
import type { InteractiveElement, ElementType } from './types';
|
|
13
|
+
|
|
14
|
+
// ─── Walk Configuration (mirrors page-agent DomConfig) ─────────
|
|
15
|
+
|
|
16
|
+
export interface WalkConfig {
|
|
17
|
+
/** React refs of elements to exclude — mirrors page-agent interactiveBlacklist */
|
|
18
|
+
interactiveBlacklist?: React.RefObject<any>[];
|
|
19
|
+
/** If set, only these elements are interactive — mirrors page-agent interactiveWhitelist */
|
|
20
|
+
interactiveWhitelist?: React.RefObject<any>[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Fiber Node Type Detection ─────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** React Native component names that are inherently interactive */
|
|
26
|
+
const PRESSABLE_TYPES = new Set([
|
|
27
|
+
'Pressable',
|
|
28
|
+
'TouchableOpacity',
|
|
29
|
+
'TouchableHighlight',
|
|
30
|
+
'TouchableWithoutFeedback',
|
|
31
|
+
'TouchableNativeFeedback',
|
|
32
|
+
'Button',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const TEXT_INPUT_TYPES = new Set(['TextInput', 'RCTSinglelineTextInputView', 'RCTMultilineTextInputView']);
|
|
36
|
+
const SWITCH_TYPES = new Set(['Switch', 'RCTSwitch']);
|
|
37
|
+
const TEXT_TYPES = new Set(['Text', 'RCTText']);
|
|
38
|
+
// ScrollView/FlatList/SectionList detection can be added later for scroll tool
|
|
39
|
+
|
|
40
|
+
// ─── State Extraction (mirrors page-agent DEFAULT_INCLUDE_ATTRIBUTES) ──
|
|
41
|
+
|
|
42
|
+
/** Props to extract as state attributes — covers lazy devs who skip accessibility */
|
|
43
|
+
const STATE_PROPS = ['value', 'checked', 'selected', 'active', 'on', 'isOn', 'toggled', 'enabled'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract state attributes from a fiber node's props.
|
|
47
|
+
* Mirrors page-agent's DEFAULT_INCLUDE_ATTRIBUTES extraction.
|
|
48
|
+
* Priority: accessibilityState > accessibilityRole > direct scalar props.
|
|
49
|
+
*/
|
|
50
|
+
function extractStateAttributes(props: any): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
|
|
53
|
+
// Priority 1: accessibilityState (proper ARIA equivalent)
|
|
54
|
+
if (props.accessibilityState && typeof props.accessibilityState === 'object') {
|
|
55
|
+
for (const [k, v] of Object.entries(props.accessibilityState)) {
|
|
56
|
+
if (v !== undefined) parts.push(`${k}="${v}"`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Priority 2: accessibilityRole
|
|
61
|
+
if (props.accessibilityRole) {
|
|
62
|
+
parts.push(`role="${props.accessibilityRole}"`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Priority 3: Direct scalar props fallback (lazy developer support)
|
|
66
|
+
for (const key of STATE_PROPS) {
|
|
67
|
+
if (props[key] !== undefined && typeof props[key] !== 'function' && typeof props[key] !== 'object') {
|
|
68
|
+
parts.push(`${key}="${props[key]}"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return parts.join(' ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a node has ANY event handler prop (on* function).
|
|
77
|
+
* Mirrors RNTL's getEventHandlerFromProps pattern.
|
|
78
|
+
*/
|
|
79
|
+
export function hasAnyEventHandler(props: any): boolean {
|
|
80
|
+
if (!props || typeof props !== 'object') return false;
|
|
81
|
+
for (const key of Object.keys(props)) {
|
|
82
|
+
if (key.startsWith('on') && typeof props[key] === 'function') {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Fiber Node Helpers ────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the display name of a Fiber node's component type.
|
|
93
|
+
*/
|
|
94
|
+
function getComponentName(fiber: any): string | null {
|
|
95
|
+
if (!fiber || !fiber.type) return null;
|
|
96
|
+
|
|
97
|
+
// Host components (View, Text, etc.) — type is a string
|
|
98
|
+
if (typeof fiber.type === 'string') return fiber.type;
|
|
99
|
+
|
|
100
|
+
// Function/Class components — type has displayName or name
|
|
101
|
+
if (fiber.type.displayName) return fiber.type.displayName;
|
|
102
|
+
if (fiber.type.name) return fiber.type.name;
|
|
103
|
+
|
|
104
|
+
// ForwardRef components
|
|
105
|
+
if (fiber.type.render?.displayName) return fiber.type.render.displayName;
|
|
106
|
+
if (fiber.type.render?.name) return fiber.type.render.name;
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a fiber node represents an interactive element.
|
|
113
|
+
*/
|
|
114
|
+
function getElementType(fiber: any): ElementType | null {
|
|
115
|
+
const name = getComponentName(fiber);
|
|
116
|
+
const props = fiber.memoizedProps || {};
|
|
117
|
+
|
|
118
|
+
// Check by component name (known React Native types)
|
|
119
|
+
if (name && PRESSABLE_TYPES.has(name)) return 'pressable';
|
|
120
|
+
if (name && TEXT_INPUT_TYPES.has(name)) return 'text-input';
|
|
121
|
+
if (name && SWITCH_TYPES.has(name)) return 'switch';
|
|
122
|
+
|
|
123
|
+
// Check by accessibilityRole (covers custom components with proper ARIA)
|
|
124
|
+
const role = props.accessibilityRole || props.role;
|
|
125
|
+
if (role === 'switch') return 'switch';
|
|
126
|
+
if (role === 'button' || role === 'link' || role === 'checkbox' || role === 'radio') {
|
|
127
|
+
return 'pressable';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check by props — any component with onPress is interactive
|
|
131
|
+
if (props.onPress && typeof props.onPress === 'function') return 'pressable';
|
|
132
|
+
|
|
133
|
+
// TextInput detection by props
|
|
134
|
+
if (props.onChangeText && typeof props.onChangeText === 'function') return 'text-input';
|
|
135
|
+
|
|
136
|
+
// Switch detection by props (custom switches with onValueChange)
|
|
137
|
+
if (props.onValueChange && typeof props.onValueChange === 'function') return 'switch';
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if element is disabled.
|
|
144
|
+
*/
|
|
145
|
+
function isDisabled(fiber: any): boolean {
|
|
146
|
+
const props = fiber.memoizedProps || {};
|
|
147
|
+
return props.disabled === true || props.editable === false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Text Extraction ───────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Recursively extract text content from a fiber's children.
|
|
154
|
+
* Stops at the next interactive element to avoid capturing text from nested buttons.
|
|
155
|
+
*/
|
|
156
|
+
function extractTextContent(fiber: any, maxDepth: number = 10): string {
|
|
157
|
+
if (!fiber || maxDepth <= 0) return '';
|
|
158
|
+
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
|
|
161
|
+
let child = fiber.child;
|
|
162
|
+
while (child) {
|
|
163
|
+
const childName = getComponentName(child);
|
|
164
|
+
const childProps = child.memoizedProps || {};
|
|
165
|
+
|
|
166
|
+
// Stop at nested interactive elements
|
|
167
|
+
if (getElementType(child) !== null && child !== fiber) {
|
|
168
|
+
child = child.sibling;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Text node — extract content
|
|
173
|
+
if (childName && TEXT_TYPES.has(childName)) {
|
|
174
|
+
const text = extractRawText(childProps.children);
|
|
175
|
+
if (text) parts.push(text);
|
|
176
|
+
} else {
|
|
177
|
+
// Recurse into non-interactive children
|
|
178
|
+
const nestedText = extractTextContent(child, maxDepth - 1);
|
|
179
|
+
if (nestedText) parts.push(nestedText);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
child = child.sibling;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return parts.join(' ').trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Extract raw text from React children prop.
|
|
190
|
+
* Handles strings, numbers, arrays, and nested structures.
|
|
191
|
+
*/
|
|
192
|
+
function extractRawText(children: any): string {
|
|
193
|
+
if (children == null) return '';
|
|
194
|
+
if (typeof children === 'string') return children;
|
|
195
|
+
if (typeof children === 'number') return String(children);
|
|
196
|
+
|
|
197
|
+
if (Array.isArray(children)) {
|
|
198
|
+
return children
|
|
199
|
+
.map(child => extractRawText(child))
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(' ');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// React element — try to extract text from its props
|
|
205
|
+
if (children && typeof children === 'object' && children.props) {
|
|
206
|
+
return extractRawText(children.props.children);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get the Fiber root node.
|
|
214
|
+
*
|
|
215
|
+
* In React Native, a View ref gives a native node, NOT a Fiber node.
|
|
216
|
+
* We use __REACT_DEVTOOLS_GLOBAL_HOOK__ (available in dev builds) to
|
|
217
|
+
* access getFiberRoots(), which is the same API React DevTools uses.
|
|
218
|
+
*
|
|
219
|
+
* Falls back to probing the ref's internal keys for Fiber references.
|
|
220
|
+
*/
|
|
221
|
+
function getFiberRoot(): any | null {
|
|
222
|
+
// Strategy 1: __REACT_DEVTOOLS_GLOBAL_HOOK__ (most reliable in dev)
|
|
223
|
+
try {
|
|
224
|
+
const hook = (globalThis as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
225
|
+
if (hook) {
|
|
226
|
+
// hook.renderers is a Map of renderer ID → renderer
|
|
227
|
+
// hook.getFiberRoots(rendererId) returns a Set of FiberRoot objects
|
|
228
|
+
const renderers = hook.renderers;
|
|
229
|
+
if (renderers && renderers.size > 0) {
|
|
230
|
+
for (const [rendererId] of renderers) {
|
|
231
|
+
const roots = hook.getFiberRoots(rendererId);
|
|
232
|
+
if (roots && roots.size > 0) {
|
|
233
|
+
// Get the first (and usually only) root
|
|
234
|
+
const fiberRoot = roots.values().next().value;
|
|
235
|
+
if (fiberRoot && fiberRoot.current) {
|
|
236
|
+
logger.debug('FiberTreeWalker', 'Accessed Fiber tree via DevTools hook');
|
|
237
|
+
return fiberRoot.current; // This is the root Fiber node
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {
|
|
244
|
+
logger.debug('FiberTreeWalker', 'DevTools hook not available:', e);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getFiberFromRef(ref: any): any | null {
|
|
251
|
+
// First try the DevTools hook (works regardless of what ref is)
|
|
252
|
+
const rootFiber = getFiberRoot();
|
|
253
|
+
if (rootFiber) return rootFiber;
|
|
254
|
+
|
|
255
|
+
if (!ref) return null;
|
|
256
|
+
|
|
257
|
+
// Fallback: Try known internal Fiber access patterns on the ref itself
|
|
258
|
+
|
|
259
|
+
// Pattern 1: _reactInternals (class components)
|
|
260
|
+
if (ref._reactInternals) return ref._reactInternals;
|
|
261
|
+
|
|
262
|
+
// Pattern 2: _reactInternalInstance (older React)
|
|
263
|
+
if (ref._reactInternalInstance) return ref._reactInternalInstance;
|
|
264
|
+
|
|
265
|
+
// Pattern 3: __reactFiber$ keys (React DOM/RN style)
|
|
266
|
+
try {
|
|
267
|
+
const keys = Object.keys(ref);
|
|
268
|
+
const fiberKey = keys.find(
|
|
269
|
+
key => key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$'),
|
|
270
|
+
);
|
|
271
|
+
if (fiberKey) return (ref as any)[fiberKey];
|
|
272
|
+
} catch {
|
|
273
|
+
// Object.keys may fail on some native nodes
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Pattern 4: Direct fiber node properties
|
|
277
|
+
if (ref.child || ref.memoizedProps) return ref;
|
|
278
|
+
|
|
279
|
+
logger.warn('FiberTreeWalker', 'All Fiber access strategies failed');
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Blacklist/Whitelist Matching ──────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check if a Fiber node matches any ref in the given list.
|
|
287
|
+
* Mirrors page-agent.js: `interactiveBlacklist.includes(element)`
|
|
288
|
+
* We compare the Fiber's stateNode (native instance) against ref.current.
|
|
289
|
+
*/
|
|
290
|
+
function matchesRefList(node: any, refs?: React.RefObject<any>[]): boolean {
|
|
291
|
+
if (!refs || refs.length === 0) return false;
|
|
292
|
+
const stateNode = node.stateNode;
|
|
293
|
+
if (!stateNode) return false;
|
|
294
|
+
|
|
295
|
+
for (const ref of refs) {
|
|
296
|
+
if (ref.current && ref.current === stateNode) return true;
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface WalkResult {
|
|
302
|
+
elementsText: string;
|
|
303
|
+
interactives: InteractiveElement[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Main Tree Walker ──────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Walk the React Fiber tree from a root and collect all interactive elements
|
|
310
|
+
* as well as a hierarchical layout representation for the LLM.
|
|
311
|
+
*/
|
|
312
|
+
export function walkFiberTree(rootRef: any, config?: WalkConfig): WalkResult {
|
|
313
|
+
const fiber = getFiberFromRef(rootRef);
|
|
314
|
+
if (!fiber) {
|
|
315
|
+
logger.warn('FiberTreeWalker', 'Could not access Fiber tree from ref');
|
|
316
|
+
return { elementsText: '', interactives: [] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const interactives: InteractiveElement[] = [];
|
|
320
|
+
let currentIndex = 0;
|
|
321
|
+
const hasWhitelist = config?.interactiveWhitelist && (config.interactiveWhitelist.length ?? 0) > 0;
|
|
322
|
+
|
|
323
|
+
function processNode(node: any, depth: number = 0, isInsideInteractive: boolean = false): string {
|
|
324
|
+
if (!node) return '';
|
|
325
|
+
|
|
326
|
+
const props = node.memoizedProps || {};
|
|
327
|
+
|
|
328
|
+
// ── Security Constraints ──
|
|
329
|
+
if (props.aiIgnore === true) return '';
|
|
330
|
+
if (matchesRefList(node, config?.interactiveBlacklist)) {
|
|
331
|
+
let childText = '';
|
|
332
|
+
let currentChild = node.child;
|
|
333
|
+
while (currentChild) {
|
|
334
|
+
childText += processNode(currentChild, depth, isInsideInteractive);
|
|
335
|
+
currentChild = currentChild.sibling;
|
|
336
|
+
}
|
|
337
|
+
return childText;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Interactive check — skip if already inside an interactive ancestor (dedup nested TextInput layers)
|
|
341
|
+
const isWhitelisted = matchesRefList(node, config?.interactiveWhitelist);
|
|
342
|
+
const elementType = getElementType(node);
|
|
343
|
+
const shouldInclude = !isInsideInteractive && (hasWhitelist ? isWhitelisted : (elementType && !isDisabled(node)));
|
|
344
|
+
|
|
345
|
+
// Process children — if this node IS interactive, children won't register as separate interactives
|
|
346
|
+
let childrenText = '';
|
|
347
|
+
let currentChild = node.child;
|
|
348
|
+
while (currentChild) {
|
|
349
|
+
childrenText += processNode(currentChild, depth + 1, isInsideInteractive || !!shouldInclude);
|
|
350
|
+
currentChild = currentChild.sibling;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const indent = ' '.repeat(depth);
|
|
354
|
+
|
|
355
|
+
if (shouldInclude) {
|
|
356
|
+
const resolvedType = elementType || 'pressable';
|
|
357
|
+
let label = props.accessibilityLabel || extractTextContent(node);
|
|
358
|
+
if (!label && resolvedType === 'text-input' && props.placeholder) {
|
|
359
|
+
label = props.placeholder;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interactives.push({
|
|
363
|
+
index: currentIndex,
|
|
364
|
+
type: resolvedType,
|
|
365
|
+
label: label || `[${resolvedType}]`,
|
|
366
|
+
fiberNode: node,
|
|
367
|
+
props: { ...props },
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Build output tag with state attributes (mirrors page-agent format)
|
|
371
|
+
const stateAttrs = extractStateAttributes(props);
|
|
372
|
+
const attrStr = stateAttrs ? ` ${stateAttrs}` : '';
|
|
373
|
+
const textContent = label || '';
|
|
374
|
+
const elementOutput = `${indent}[${currentIndex}]<${resolvedType}${attrStr}>${textContent} />${childrenText.trim() ? '\n' + childrenText : ''}\n`;
|
|
375
|
+
currentIndex++;
|
|
376
|
+
return elementOutput;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Non-interactive structural nodes
|
|
380
|
+
const typeStr = node.type && typeof node.type === 'string' ? node.type :
|
|
381
|
+
(node.elementType && typeof node.elementType === 'string' ? node.elementType : null);
|
|
382
|
+
|
|
383
|
+
if (typeStr === 'RCTText' || typeStr === 'Text') {
|
|
384
|
+
const textContent = extractRawText(props.children);
|
|
385
|
+
if (textContent && textContent.trim() !== '') {
|
|
386
|
+
return `${indent}<text>${textContent.trim()}</text>\n`;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (childrenText.trim() !== '') {
|
|
391
|
+
return `${indent}<view>\n${childrenText}${indent}</view>\n`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return '';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let elementsText = processNode(fiber, 0);
|
|
398
|
+
|
|
399
|
+
// Clean up empty views and excessive newlines
|
|
400
|
+
elementsText = elementsText.replace(/<view>\s*<\/view>\n?/g, '');
|
|
401
|
+
|
|
402
|
+
logger.info('FiberTreeWalker', `Found ${interactives.length} interactive elements`);
|
|
403
|
+
return { elementsText: elementsText.trim(), interactives };
|
|
404
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPBridge — Connects the React Native app to the local MCP Server bridge.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* - Connects via WebSocket to the Node.js MCP server
|
|
6
|
+
* - Listens for 'request' messages containing an MCP command
|
|
7
|
+
* - Forwards the command to AgentRuntime.execute()
|
|
8
|
+
* - Sends the ExecutionResult back via WebSocket as a 'response'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from '../utils/logger';
|
|
12
|
+
import type { AgentRuntime } from './AgentRuntime';
|
|
13
|
+
|
|
14
|
+
export class MCPBridge {
|
|
15
|
+
private url: string;
|
|
16
|
+
private ws: WebSocket | null = null;
|
|
17
|
+
private runtime: AgentRuntime;
|
|
18
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
19
|
+
private isDestroyed = false;
|
|
20
|
+
|
|
21
|
+
constructor(url: string, runtime: AgentRuntime) {
|
|
22
|
+
this.url = url;
|
|
23
|
+
this.runtime = runtime;
|
|
24
|
+
this.connect();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private connect() {
|
|
28
|
+
if (this.isDestroyed) return;
|
|
29
|
+
|
|
30
|
+
logger.info('MCPBridge', `Connecting to MCP bridge at ${this.url}...`);
|
|
31
|
+
this.ws = new WebSocket(this.url);
|
|
32
|
+
|
|
33
|
+
this.ws.onopen = () => {
|
|
34
|
+
logger.info('MCPBridge', '✅ Connected to MCP bridge.');
|
|
35
|
+
if (this.reconnectTimer) {
|
|
36
|
+
clearTimeout(this.reconnectTimer);
|
|
37
|
+
this.reconnectTimer = null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
this.ws.onmessage = async (event) => {
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(event.data);
|
|
44
|
+
if (data.type === 'request' && data.command && data.requestId) {
|
|
45
|
+
logger.info('MCPBridge', `Received task from MCP: "${data.command}"`);
|
|
46
|
+
|
|
47
|
+
if (this.runtime.getIsRunning()) {
|
|
48
|
+
this.sendResponse(data.requestId, {
|
|
49
|
+
success: false,
|
|
50
|
+
message: 'Agent is already running a task. Please wait.',
|
|
51
|
+
steps: [],
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Execute the task using the SDK's existing runtime loop
|
|
57
|
+
const result = await this.runtime.execute(data.command);
|
|
58
|
+
|
|
59
|
+
// Send result back to MCP server
|
|
60
|
+
this.sendResponse(data.requestId, result);
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
logger.error('MCPBridge', 'Error handling message:', err);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this.ws.onclose = () => {
|
|
68
|
+
if (!this.isDestroyed) {
|
|
69
|
+
logger.warn('MCPBridge', 'Disconnected from MCP bridge. Reconnecting in 5s...');
|
|
70
|
+
this.ws = null;
|
|
71
|
+
this.scheduleReconnect();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.ws.onerror = (e) => {
|
|
76
|
+
logger.warn('MCPBridge', 'WebSocket error:', e);
|
|
77
|
+
// onclose will handle reconnect
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private sendResponse(requestId: string, payload: any) {
|
|
82
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
83
|
+
this.ws.send(JSON.stringify({
|
|
84
|
+
type: 'response',
|
|
85
|
+
requestId,
|
|
86
|
+
payload,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private scheduleReconnect() {
|
|
92
|
+
if (!this.reconnectTimer) {
|
|
93
|
+
this.reconnectTimer = setTimeout(() => {
|
|
94
|
+
this.connect();
|
|
95
|
+
}, 5000);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public destroy() {
|
|
100
|
+
this.isDestroyed = true;
|
|
101
|
+
if (this.reconnectTimer) {
|
|
102
|
+
clearTimeout(this.reconnectTimer);
|
|
103
|
+
this.reconnectTimer = null;
|
|
104
|
+
}
|
|
105
|
+
if (this.ws) {
|
|
106
|
+
this.ws.close();
|
|
107
|
+
this.ws = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScreenDehydrator — Converts discovered interactive elements into
|
|
3
|
+
* a text representation for the LLM, matching page-agent.js format.
|
|
4
|
+
*
|
|
5
|
+
* Output example:
|
|
6
|
+
* ```
|
|
7
|
+
* Screen: Home | Available screens: Home, Menu, Cart
|
|
8
|
+
* Interactive elements:
|
|
9
|
+
* [0]<pressable>🍕 Pizzas</>
|
|
10
|
+
* [1]<pressable>🍔 Burgers</>
|
|
11
|
+
* [2]<pressable>🥤 Drinks</>
|
|
12
|
+
* [3]<pressable>🛒 View Cart</>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { InteractiveElement, DehydratedScreen } from './types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dehydrate the current screen state into a text format for the LLM.
|
|
20
|
+
*/
|
|
21
|
+
export function dehydrateScreen(
|
|
22
|
+
screenName: string,
|
|
23
|
+
availableScreens: string[],
|
|
24
|
+
elementsText: string,
|
|
25
|
+
elements: InteractiveElement[],
|
|
26
|
+
): DehydratedScreen {
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
|
|
29
|
+
// Header
|
|
30
|
+
lines.push(`Screen: ${screenName} | Available screens: ${availableScreens.join(', ')}`);
|
|
31
|
+
lines.push('');
|
|
32
|
+
|
|
33
|
+
if (!elementsText || elementsText.trim().length === 0) {
|
|
34
|
+
if (elements.length === 0) {
|
|
35
|
+
lines.push('No interactive elements or visible text detected on this screen.');
|
|
36
|
+
} else {
|
|
37
|
+
lines.push('Interactive elements:');
|
|
38
|
+
lines.push(elementsText);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
lines.push('Screen Layout & Elements:');
|
|
42
|
+
lines.push(elementsText);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const finalElementsText = lines.join('\n');
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
screenName,
|
|
49
|
+
availableScreens,
|
|
50
|
+
elementsText: finalElementsText,
|
|
51
|
+
elements,
|
|
52
|
+
};
|
|
53
|
+
}
|