@mobileai/react-native 0.9.0 → 0.9.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 +121 -0
- package/lib/module/components/AIAgent.js +248 -53
- package/lib/module/components/AIAgent.js.map +1 -1
- package/lib/module/components/AIZone.js +140 -0
- package/lib/module/components/AIZone.js.map +1 -0
- package/lib/module/components/AgentErrorBoundary.js +9 -0
- package/lib/module/components/AgentErrorBoundary.js.map +1 -1
- package/lib/module/components/HighlightOverlay.js +138 -0
- package/lib/module/components/HighlightOverlay.js.map +1 -0
- package/lib/module/components/ProactiveHint.js +138 -0
- package/lib/module/components/ProactiveHint.js.map +1 -0
- package/lib/module/components/cards/InfoCard.js +65 -0
- package/lib/module/components/cards/InfoCard.js.map +1 -0
- package/lib/module/components/cards/ReviewSummary.js +74 -0
- package/lib/module/components/cards/ReviewSummary.js.map +1 -0
- package/lib/module/core/AgentRuntime.js +16 -3
- package/lib/module/core/AgentRuntime.js.map +1 -1
- package/lib/module/core/FiberTreeWalker.js +62 -85
- package/lib/module/core/FiberTreeWalker.js.map +1 -1
- package/lib/module/core/IdleDetector.js +51 -0
- package/lib/module/core/IdleDetector.js.map +1 -0
- package/lib/module/core/ZoneRegistry.js +47 -0
- package/lib/module/core/ZoneRegistry.js.map +1 -0
- package/lib/module/core/systemPrompt.js +2 -0
- package/lib/module/core/systemPrompt.js.map +1 -1
- package/lib/module/index.js +21 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/services/AudioOutputService.js +10 -0
- package/lib/module/services/AudioOutputService.js.map +1 -1
- package/lib/module/services/flags/FlagService.js +117 -0
- package/lib/module/services/flags/FlagService.js.map +1 -0
- package/lib/module/services/telemetry/MobileAI.js +66 -0
- package/lib/module/services/telemetry/MobileAI.js.map +1 -0
- package/lib/module/services/telemetry/PiiScrubber.js +17 -0
- package/lib/module/services/telemetry/PiiScrubber.js.map +1 -0
- package/lib/module/services/telemetry/TelemetryService.js +260 -0
- package/lib/module/services/telemetry/TelemetryService.js.map +1 -0
- package/lib/module/services/telemetry/TouchAutoCapture.js +159 -0
- package/lib/module/services/telemetry/TouchAutoCapture.js.map +1 -0
- package/lib/module/services/telemetry/device.js +19 -0
- package/lib/module/services/telemetry/device.js.map +1 -0
- package/lib/module/services/telemetry/index.js +9 -0
- package/lib/module/services/telemetry/index.js.map +1 -0
- package/lib/module/services/telemetry/types.js +2 -0
- package/lib/module/services/telemetry/types.js.map +1 -0
- package/lib/module/support/CSATSurvey.js +273 -0
- package/lib/module/support/CSATSurvey.js.map +1 -0
- package/lib/module/support/EscalationSocket.js +92 -0
- package/lib/module/support/EscalationSocket.js.map +1 -0
- package/lib/module/support/SupportGreeting.js +142 -0
- package/lib/module/support/SupportGreeting.js.map +1 -0
- package/lib/module/support/escalateTool.js +120 -0
- package/lib/module/support/escalateTool.js.map +1 -0
- package/lib/module/support/index.js +18 -0
- package/lib/module/support/index.js.map +1 -0
- package/lib/module/support/supportPrompt.js +47 -0
- package/lib/module/support/supportPrompt.js.map +1 -0
- package/lib/module/support/types.js +2 -0
- package/lib/module/support/types.js.map +1 -0
- package/lib/module/tools/guideTool.js +61 -0
- package/lib/module/tools/guideTool.js.map +1 -0
- package/lib/module/tools/index.js +3 -0
- package/lib/module/tools/index.js.map +1 -1
- package/lib/module/tools/restoreTool.js +31 -0
- package/lib/module/tools/restoreTool.js.map +1 -0
- package/lib/module/tools/simplifyTool.js +31 -0
- package/lib/module/tools/simplifyTool.js.map +1 -0
- package/lib/module/types/jsx.d.js +4 -0
- package/lib/module/types/jsx.d.js.map +1 -0
- package/lib/typescript/src/components/AIAgent.d.ts +21 -2
- package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
- package/lib/typescript/src/components/AIZone.d.ts +16 -0
- package/lib/typescript/src/components/AIZone.d.ts.map +1 -0
- package/lib/typescript/src/components/AgentErrorBoundary.d.ts +1 -0
- package/lib/typescript/src/components/AgentErrorBoundary.d.ts.map +1 -1
- package/lib/typescript/src/components/HighlightOverlay.d.ts +10 -0
- package/lib/typescript/src/components/HighlightOverlay.d.ts.map +1 -0
- package/lib/typescript/src/components/ProactiveHint.d.ts +10 -0
- package/lib/typescript/src/components/ProactiveHint.d.ts.map +1 -0
- package/lib/typescript/src/components/cards/InfoCard.d.ts +19 -0
- package/lib/typescript/src/components/cards/InfoCard.d.ts.map +1 -0
- package/lib/typescript/src/components/cards/ReviewSummary.d.ts +19 -0
- package/lib/typescript/src/components/cards/ReviewSummary.d.ts.map +1 -0
- package/lib/typescript/src/core/AgentRuntime.d.ts +1 -0
- package/lib/typescript/src/core/AgentRuntime.d.ts.map +1 -1
- package/lib/typescript/src/core/FiberTreeWalker.d.ts.map +1 -1
- package/lib/typescript/src/core/IdleDetector.d.ts +27 -0
- package/lib/typescript/src/core/IdleDetector.d.ts.map +1 -0
- package/lib/typescript/src/core/ZoneRegistry.d.ts +13 -0
- package/lib/typescript/src/core/ZoneRegistry.d.ts.map +1 -0
- package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -1
- package/lib/typescript/src/core/types.d.ts +54 -0
- package/lib/typescript/src/core/types.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/services/AudioOutputService.d.ts.map +1 -1
- package/lib/typescript/src/services/flags/FlagService.d.ts +25 -0
- package/lib/typescript/src/services/flags/FlagService.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/MobileAI.d.ts +38 -0
- package/lib/typescript/src/services/telemetry/MobileAI.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/PiiScrubber.d.ts +6 -0
- package/lib/typescript/src/services/telemetry/PiiScrubber.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +49 -0
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/TouchAutoCapture.d.ts +28 -0
- package/lib/typescript/src/services/telemetry/TouchAutoCapture.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/device.d.ts +7 -0
- package/lib/typescript/src/services/telemetry/device.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/index.d.ts +7 -0
- package/lib/typescript/src/services/telemetry/index.d.ts.map +1 -0
- package/lib/typescript/src/services/telemetry/types.d.ts +50 -0
- package/lib/typescript/src/services/telemetry/types.d.ts.map +1 -0
- package/lib/typescript/src/support/CSATSurvey.d.ts +20 -0
- package/lib/typescript/src/support/CSATSurvey.d.ts.map +1 -0
- package/lib/typescript/src/support/EscalationSocket.d.ts +38 -0
- package/lib/typescript/src/support/EscalationSocket.d.ts.map +1 -0
- package/lib/typescript/src/support/SupportGreeting.d.ts +19 -0
- package/lib/typescript/src/support/SupportGreeting.d.ts.map +1 -0
- package/lib/typescript/src/support/escalateTool.d.ts +25 -0
- package/lib/typescript/src/support/escalateTool.d.ts.map +1 -0
- package/lib/typescript/src/support/index.d.ts +11 -0
- package/lib/typescript/src/support/index.d.ts.map +1 -0
- package/lib/typescript/src/support/supportPrompt.d.ts +12 -0
- package/lib/typescript/src/support/supportPrompt.d.ts.map +1 -0
- package/lib/typescript/src/support/types.d.ts +114 -0
- package/lib/typescript/src/support/types.d.ts.map +1 -0
- package/lib/typescript/src/tools/guideTool.d.ts +4 -0
- package/lib/typescript/src/tools/guideTool.d.ts.map +1 -0
- package/lib/typescript/src/tools/index.d.ts +3 -0
- package/lib/typescript/src/tools/index.d.ts.map +1 -1
- package/lib/typescript/src/tools/restoreTool.d.ts +3 -0
- package/lib/typescript/src/tools/restoreTool.d.ts.map +1 -0
- package/lib/typescript/src/tools/simplifyTool.d.ts +3 -0
- package/lib/typescript/src/tools/simplifyTool.d.ts.map +1 -0
- package/lib/typescript/src/tools/types.d.ts +2 -0
- package/lib/typescript/src/tools/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/AIAgent.tsx +253 -15
- package/src/components/AIZone.tsx +147 -0
- package/src/components/AgentErrorBoundary.tsx +10 -0
- package/src/components/HighlightOverlay.tsx +136 -0
- package/src/components/ProactiveHint.tsx +145 -0
- package/src/components/cards/InfoCard.tsx +58 -0
- package/src/components/cards/ReviewSummary.tsx +76 -0
- package/src/core/AgentRuntime.ts +18 -0
- package/src/core/FiberTreeWalker.ts +71 -93
- package/src/core/IdleDetector.ts +72 -0
- package/src/core/ZoneRegistry.ts +44 -0
- package/src/core/systemPrompt.ts +2 -0
- package/src/core/types.ts +60 -0
- package/src/index.ts +31 -0
- package/src/services/AudioOutputService.ts +13 -0
- package/src/services/flags/FlagService.ts +137 -0
- package/src/services/telemetry/MobileAI.ts +66 -0
- package/src/services/telemetry/PiiScrubber.ts +17 -0
- package/src/services/telemetry/TelemetryService.ts +291 -0
- package/src/services/telemetry/TouchAutoCapture.ts +165 -0
- package/src/services/telemetry/device.ts +16 -0
- package/src/services/telemetry/index.ts +13 -0
- package/src/services/telemetry/types.ts +75 -0
- package/src/support/CSATSurvey.tsx +304 -0
- package/src/support/EscalationSocket.ts +113 -0
- package/src/support/SupportGreeting.tsx +161 -0
- package/src/support/escalateTool.ts +134 -0
- package/src/support/index.ts +27 -0
- package/src/support/supportPrompt.ts +55 -0
- package/src/support/types.ts +141 -0
- package/src/tools/guideTool.ts +67 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/restoreTool.ts +33 -0
- package/src/tools/simplifyTool.ts +33 -0
- package/src/tools/types.ts +2 -0
- package/src/types/jsx.d.ts +20 -0
|
@@ -48,19 +48,7 @@ const VIDEO_TYPES = new Set([
|
|
|
48
48
|
'Video', 'ExpoVideo', 'RCTVideo', 'VideoPlayer', 'VideoView',
|
|
49
49
|
]);
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
// (as siblings of the navigation container), so the screen-scoped walker misses them.
|
|
53
|
-
// We detect these by name and walk them in a second pass.
|
|
54
|
-
const OVERLAY_COMPONENT_NAMES = new Set([
|
|
55
|
-
// Toast libraries
|
|
56
|
-
'Toast', 'ToastContainer', 'ToastRender', 'FlashMessage', 'DropdownAlert',
|
|
57
|
-
'SnackBar', 'Snackbar', 'NotificationContainer',
|
|
58
|
-
// Dialog/Alert libraries
|
|
59
|
-
'Dialog', 'DialogContainer', 'AlertDialog',
|
|
60
|
-
// Modal/BottomSheet
|
|
61
|
-
'BottomSheet', 'ActionSheet', 'RBSheet', 'BottomSheetModal',
|
|
62
|
-
'Popup', 'PopupContainer', 'Tooltip',
|
|
63
|
-
]);
|
|
51
|
+
|
|
64
52
|
|
|
65
53
|
// Known RN internal component names to skip when walking up for context
|
|
66
54
|
const RN_INTERNAL_NAMES = new Set([
|
|
@@ -442,70 +430,10 @@ export interface WalkResult {
|
|
|
442
430
|
interactives: InteractiveElement[];
|
|
443
431
|
}
|
|
444
432
|
|
|
445
|
-
// ───
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
* that render OUTSIDE the active screen's subtree.
|
|
450
|
-
*
|
|
451
|
-
* Toast/dialog libraries (react-native-alert-notification, react-native-flash-message, etc.)
|
|
452
|
-
* typically render their overlay as a SIBLING of the navigation container:
|
|
453
|
-
* <Root>
|
|
454
|
-
* {children} ← navigation tree (contains screens)
|
|
455
|
-
* <Toast ... /> ← overlay (outside screen subtree)
|
|
456
|
-
* </Root>
|
|
457
|
-
*
|
|
458
|
-
* This function finds those overlay nodes so the main walker can include them.
|
|
459
|
-
*/
|
|
460
|
-
function findOverlayNodes(rootFiber: any, screenSubtree: any): any[] {
|
|
461
|
-
const overlays: any[] = [];
|
|
462
|
-
|
|
463
|
-
function scan(node: any): void {
|
|
464
|
-
if (!node) return;
|
|
465
|
-
|
|
466
|
-
// Skip the screen subtree itself (already walked by main pass)
|
|
467
|
-
if (node === screenSubtree) {
|
|
468
|
-
// Don't scan children, but DO scan siblings
|
|
469
|
-
if (node.sibling) scan(node.sibling);
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const name = getComponentName(node);
|
|
474
|
-
const props = node.memoizedProps || {};
|
|
475
|
-
const style = props.style || {};
|
|
476
|
-
// Flatten array styles (StyleSheet.flatten equivalent)
|
|
477
|
-
const flatStyle = Array.isArray(style)
|
|
478
|
-
? Object.assign({}, ...style.filter(Boolean))
|
|
479
|
-
: style;
|
|
480
|
-
|
|
481
|
-
// Detection: known overlay component name
|
|
482
|
-
const isOverlayByName = name && OVERLAY_COMPONENT_NAMES.has(name);
|
|
483
|
-
|
|
484
|
-
// Detection: position absolute with content (likely a floating overlay)
|
|
485
|
-
const isOverlayByStyle = flatStyle.position === 'absolute' &&
|
|
486
|
-
(flatStyle.zIndex > 100 || flatStyle.elevation > 10);
|
|
487
|
-
|
|
488
|
-
if (isOverlayByName || isOverlayByStyle) {
|
|
489
|
-
// Only include if the overlay has visible content (not empty/hidden)
|
|
490
|
-
const hasChildren = !!node.child;
|
|
491
|
-
const isHidden = flatStyle.opacity === 0 || flatStyle.display === 'none';
|
|
492
|
-
if (hasChildren && !isHidden) {
|
|
493
|
-
logger.debug('FiberTreeWalker', `Found overlay: ${name || 'unnamed'} (byName=${isOverlayByName}, byStyle=${isOverlayByStyle})`);
|
|
494
|
-
overlays.push(node);
|
|
495
|
-
// Don't scan children of found overlays (the main walker will handle them)
|
|
496
|
-
if (node.sibling) scan(node.sibling);
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Continue scanning children and siblings
|
|
502
|
-
if (node.child) scan(node.child);
|
|
503
|
-
if (node.sibling) scan(node.sibling);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
scan(rootFiber);
|
|
507
|
-
return overlays;
|
|
508
|
-
}
|
|
433
|
+
// ─── Note on overlays ──────────────────────────────────────────
|
|
434
|
+
// Toasts, modals, and dialogs are now captured naturally because
|
|
435
|
+
// walkFiberTree starts from the root Fiber and prunes only
|
|
436
|
+
// display:none nodes (inactive screens). No separate overlay scan needed.
|
|
509
437
|
|
|
510
438
|
// ─── Main Tree Walker ──────────────────────────────────────────
|
|
511
439
|
|
|
@@ -520,31 +448,50 @@ export function walkFiberTree(rootRef: any, config?: WalkConfig): WalkResult {
|
|
|
520
448
|
return { elementsText: '', interactives: [] };
|
|
521
449
|
}
|
|
522
450
|
|
|
523
|
-
//
|
|
524
|
-
|
|
451
|
+
// Always walk from the root Fiber — inactive screens are pruned inside
|
|
452
|
+
// processNode by checking for display:none, which React Navigation applies
|
|
453
|
+
// to all inactive screens. This ensures navigation chrome (tab bar, header)
|
|
454
|
+
// is always included without hardcoding any component names.
|
|
455
|
+
const startNode = fiber;
|
|
525
456
|
if (config?.screenName) {
|
|
526
|
-
|
|
527
|
-
if (screenFiber) {
|
|
528
|
-
startNode = screenFiber;
|
|
529
|
-
logger.debug('FiberTreeWalker', `Walk scoped to screen "${config.screenName}" (component: ${getComponentName(screenFiber)})`);
|
|
530
|
-
} else {
|
|
531
|
-
logger.debug('FiberTreeWalker', `Screen "${config.screenName}" not found in Fiber tree — searching entire tree`);
|
|
532
|
-
}
|
|
457
|
+
logger.debug('FiberTreeWalker', `Walk active for screen "${config.screenName}" (inactive screens pruned via display:none)`);
|
|
533
458
|
}
|
|
534
459
|
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
const overlayNodes =
|
|
460
|
+
// Overlay detection is superseded by root-level walk — all visible nodes
|
|
461
|
+
// (toasts, modals, overlays) are included naturally since they aren't hidden.
|
|
462
|
+
const overlayNodes: any[] = [];
|
|
538
463
|
|
|
539
464
|
const interactives: InteractiveElement[] = [];
|
|
540
465
|
let currentIndex = 0;
|
|
541
466
|
const hasWhitelist = config?.interactiveWhitelist && (config.interactiveWhitelist.length ?? 0) > 0;
|
|
542
467
|
|
|
543
|
-
function processNode(
|
|
468
|
+
function processNode(
|
|
469
|
+
node: any,
|
|
470
|
+
depth: number = 0,
|
|
471
|
+
isInsideInteractive: boolean = false,
|
|
472
|
+
ancestorOnPress: any = null,
|
|
473
|
+
currentZoneId: string | undefined = undefined
|
|
474
|
+
): string {
|
|
544
475
|
if (!node) return '';
|
|
545
476
|
|
|
546
477
|
const props = node.memoizedProps || {};
|
|
547
478
|
|
|
479
|
+
// ── Prune inactive screens ──────────────────────────────────
|
|
480
|
+
// Two mechanisms cover all React Navigation setups:
|
|
481
|
+
//
|
|
482
|
+
// 1. react-native-screens (Expo default, recommended):
|
|
483
|
+
// React Navigation sets activityState=0 on inactive tab screens.
|
|
484
|
+
// Stack screens retain activityState=2 even in the background (NativeStack
|
|
485
|
+
// never decreases activityState — confirmed from react-native-screens source).
|
|
486
|
+
// Those are kept as useful navigation history context for the agent.
|
|
487
|
+
//
|
|
488
|
+
// 2. ResourceSavingScene (React Navigation fallback without react-native-screens):
|
|
489
|
+
// Wraps inactive screen content with pointerEvents="none". Prune any such wrapper.
|
|
490
|
+
//
|
|
491
|
+
// Both are prop-based — no component names, no CSS display property.
|
|
492
|
+
if (props.activityState === 0) return '';
|
|
493
|
+
if (props.pointerEvents === 'none') return '';
|
|
494
|
+
|
|
548
495
|
// ── Security Constraints ──
|
|
549
496
|
if (props.aiIgnore === true) return '';
|
|
550
497
|
if (matchesRefList(node, config?.interactiveBlacklist)) {
|
|
@@ -591,6 +538,25 @@ export function walkFiberTree(rootRef: any, config?: WalkConfig): WalkResult {
|
|
|
591
538
|
const producesOutput = shouldInclude || isTextNode || isImageNode || isVideoNode;
|
|
592
539
|
const childDepth = producesOutput ? depth + 1 : depth;
|
|
593
540
|
|
|
541
|
+
const nextZoneId = componentName === 'AIZone' && props.id ? props.id : currentZoneId;
|
|
542
|
+
|
|
543
|
+
const indent = ' '.repeat(depth);
|
|
544
|
+
|
|
545
|
+
// ── Zone Header Emission ──────────────────────────────────
|
|
546
|
+
// When entering an AIZone boundary, emit a section header so the agent
|
|
547
|
+
// knows this zone exists, what its id is, and what it's allowed to do.
|
|
548
|
+
// This is what lets the agent call simplify_zone / guide_user proactively.
|
|
549
|
+
let zoneHeader = '';
|
|
550
|
+
if (componentName === 'AIZone' && props.id) {
|
|
551
|
+
const permissions: string[] = [];
|
|
552
|
+
if (props.allowSimplify) permissions.push('simplify');
|
|
553
|
+
if (props.allowHighlight) permissions.push('highlight');
|
|
554
|
+
if (props.allowInjectHint) permissions.push('hint');
|
|
555
|
+
if (props.allowInjectCard) permissions.push('card');
|
|
556
|
+
const permStr = permissions.length ? ` | permissions: ${permissions.join(', ')}` : '';
|
|
557
|
+
zoneHeader = `${indent}[zone: ${props.id}${permStr}]\n`;
|
|
558
|
+
}
|
|
559
|
+
|
|
594
560
|
// Process children
|
|
595
561
|
let childrenText = '';
|
|
596
562
|
let currentChild = node.child;
|
|
@@ -600,11 +566,15 @@ export function walkFiberTree(rootRef: any, config?: WalkConfig): WalkResult {
|
|
|
600
566
|
childDepth,
|
|
601
567
|
isInsideInteractive || !!shouldInclude,
|
|
602
568
|
nextAncestorOnPress,
|
|
569
|
+
nextZoneId,
|
|
603
570
|
);
|
|
604
571
|
currentChild = currentChild.sibling;
|
|
605
572
|
}
|
|
606
573
|
|
|
607
|
-
|
|
574
|
+
// Prepend zone header before children if this is a zone root
|
|
575
|
+
if (zoneHeader) {
|
|
576
|
+
childrenText = zoneHeader + childrenText;
|
|
577
|
+
}
|
|
608
578
|
|
|
609
579
|
if (shouldInclude) {
|
|
610
580
|
const resolvedType = elementType || 'pressable';
|
|
@@ -633,13 +603,20 @@ export function walkFiberTree(rootRef: any, config?: WalkConfig): WalkResult {
|
|
|
633
603
|
index: currentIndex,
|
|
634
604
|
type: resolvedType,
|
|
635
605
|
label: label || `[${resolvedType}]`,
|
|
606
|
+
aiPriority: props.aiPriority,
|
|
607
|
+
zoneId: currentZoneId,
|
|
636
608
|
fiberNode: node,
|
|
637
609
|
props: { ...props },
|
|
638
610
|
});
|
|
639
611
|
|
|
640
612
|
// Build output tag with state attributes
|
|
641
613
|
const stateAttrs = extractStateAttributes(props);
|
|
642
|
-
|
|
614
|
+
let attrStr = stateAttrs ? ` ${stateAttrs}` : '';
|
|
615
|
+
if (props.aiPriority) {
|
|
616
|
+
attrStr += ` aiPriority="${props.aiPriority}"`;
|
|
617
|
+
if (currentZoneId) attrStr += ` zoneId="${currentZoneId}"`;
|
|
618
|
+
}
|
|
619
|
+
|
|
643
620
|
const textContent = label || '';
|
|
644
621
|
const elementOutput = `${indent}[${currentIndex}]<${resolvedType}${attrStr}>${textContent} />${childrenText.trim() ? '\n' + childrenText : ''}\n`;
|
|
645
622
|
currentIndex++;
|
|
@@ -651,7 +628,8 @@ export function walkFiberTree(rootRef: any, config?: WalkConfig): WalkResult {
|
|
|
651
628
|
if (isTextNode) {
|
|
652
629
|
const textContent = extractRawText(props.children);
|
|
653
630
|
if (textContent && textContent.trim() !== '') {
|
|
654
|
-
|
|
631
|
+
const priorityTag = props.aiPriority ? ` (aiPriority="${props.aiPriority}"${currentZoneId ? ` zoneId="${currentZoneId}"` : ''})` : '';
|
|
632
|
+
return `${indent}${textContent.trim()}${priorityTag}\n`;
|
|
655
633
|
}
|
|
656
634
|
}
|
|
657
635
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface IdleDetectorConfig {
|
|
2
|
+
/** Time in ms before the agent pulses subtly (e.g. 120_000 for 2m) */
|
|
3
|
+
pulseAfterMs: number;
|
|
4
|
+
/** Time in ms before the agent shows a badge (e.g. 240_000 for 4m) */
|
|
5
|
+
badgeAfterMs: number;
|
|
6
|
+
/** Callback fired when the user is idle enough for a subtle pulse */
|
|
7
|
+
onPulse: () => void;
|
|
8
|
+
/** Callback fired when the user is idle enough for a proactive badge. Receives the context suggestion. */
|
|
9
|
+
onBadge: (suggestion: string) => void;
|
|
10
|
+
/** Callback fired when the user interacts, cancelling idle states */
|
|
11
|
+
onReset: () => void;
|
|
12
|
+
/** Dynamic context suggestion generator based on current screen */
|
|
13
|
+
generateSuggestion?: () => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class IdleDetector {
|
|
17
|
+
private pulseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
private badgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
19
|
+
private dismissed = false;
|
|
20
|
+
private config: IdleDetectorConfig | null = null;
|
|
21
|
+
|
|
22
|
+
start(config: IdleDetectorConfig): void {
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.dismissed = false;
|
|
25
|
+
this.resetTimers();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
reset(): void {
|
|
29
|
+
if (!this.config || this.dismissed) return;
|
|
30
|
+
this.config.onReset();
|
|
31
|
+
this.resetTimers();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
dismiss(): void {
|
|
35
|
+
this.dismissed = true;
|
|
36
|
+
this.clearTimers();
|
|
37
|
+
if (this.config) {
|
|
38
|
+
this.config.onReset();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
destroy(): void {
|
|
43
|
+
this.clearTimers();
|
|
44
|
+
this.config = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private resetTimers(): void {
|
|
48
|
+
this.clearTimers();
|
|
49
|
+
|
|
50
|
+
if (!this.config || this.dismissed) return;
|
|
51
|
+
|
|
52
|
+
this.pulseTimer = setTimeout(() => {
|
|
53
|
+
this.config?.onPulse();
|
|
54
|
+
}, this.config.pulseAfterMs);
|
|
55
|
+
|
|
56
|
+
this.badgeTimer = setTimeout(() => {
|
|
57
|
+
const suggestion = this.config?.generateSuggestion?.() ?? "Need help with this screen?";
|
|
58
|
+
this.config?.onBadge(suggestion);
|
|
59
|
+
}, this.config.badgeAfterMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private clearTimers(): void {
|
|
63
|
+
if (this.pulseTimer) {
|
|
64
|
+
clearTimeout(this.pulseTimer);
|
|
65
|
+
this.pulseTimer = null;
|
|
66
|
+
}
|
|
67
|
+
if (this.badgeTimer) {
|
|
68
|
+
clearTimeout(this.badgeTimer);
|
|
69
|
+
this.badgeTimer = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { createContext } from 'react';
|
|
2
|
+
import type { AIZoneConfig, RegisteredZone } from './types';
|
|
3
|
+
|
|
4
|
+
export class ZoneRegistry {
|
|
5
|
+
private zones = new Map<string, RegisteredZone>();
|
|
6
|
+
|
|
7
|
+
register(config: AIZoneConfig, ref: React.RefObject<any>): void {
|
|
8
|
+
if (this.zones.has(config.id)) {
|
|
9
|
+
console.warn(`[MobileAI] Zone ID "${config.id}" is already registered on this screen. Overwriting.`);
|
|
10
|
+
}
|
|
11
|
+
this.zones.set(config.id, { ...config, ref });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
unregister(id: string): void {
|
|
15
|
+
this.zones.delete(id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(id: string): RegisteredZone | undefined {
|
|
19
|
+
return this.zones.get(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getAll(): RegisteredZone[] {
|
|
23
|
+
return Array.from(this.zones.values());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isActionAllowed(zoneId: string, action: 'highlight' | 'hint' | 'simplify' | 'card'): boolean {
|
|
27
|
+
const zone = this.get(zoneId);
|
|
28
|
+
if (!zone) return false;
|
|
29
|
+
|
|
30
|
+
switch (action) {
|
|
31
|
+
case 'highlight': return !!zone.allowHighlight;
|
|
32
|
+
case 'hint': return !!zone.allowInjectHint;
|
|
33
|
+
case 'simplify': return !!zone.allowSimplify;
|
|
34
|
+
case 'card': return !!zone.allowInjectCard;
|
|
35
|
+
default: return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Global registry instance shared across the Agent session
|
|
41
|
+
export const globalZoneRegistry = new ZoneRegistry();
|
|
42
|
+
|
|
43
|
+
// Export context so AIZone components can register themselves
|
|
44
|
+
export const ZoneRegistryContext = createContext<ZoneRegistry>(globalZoneRegistry);
|
package/src/core/systemPrompt.ts
CHANGED
|
@@ -102,6 +102,7 @@ If a UI element is hidden (aiIgnore) but a matching custom action exists, use th
|
|
|
102
102
|
- Trying too hard can be harmful. If stuck, call done() with partial results rather than repeating failed actions.
|
|
103
103
|
- If you do not know how to proceed with the current screen, use ask_user to request specific instructions from the user.
|
|
104
104
|
- NAVIGATION: Always use tap actions to move between screens — tap tab bar buttons, back buttons, and navigation links. This ensures all required route params (like item IDs) are passed automatically by the app. The navigate() tool is ONLY for top-level screens that require no params (e.g. Login, Settings, Cart). NEVER call navigate() on screens that require a selection or ID (e.g. DishDetail, SelectCategory, ProfileDetail) — this will crash the app. For those screens, always tap the relevant item in the parent screen.
|
|
105
|
+
- UI SIMPLIFICATION: If you see elements labeled \`aiPriority="low"\` inside a specific \`zoneId=...\`, and the screen looks cluttered or overwhelming to the user's immediate goal, use the \`simplify_zone(zoneId)\` tool to hide those elements. Use \`restore_zone(zoneId)\` to bring them back if needed later!
|
|
105
106
|
</rules>
|
|
106
107
|
|
|
107
108
|
<task_completion_rules>
|
|
@@ -271,6 +272,7 @@ If a UI element is hidden but a matching custom action exists, use the action.
|
|
|
271
272
|
- Do NOT ask for confirmation of actions the user explicitly requested. If they said "place my order", just do it.
|
|
272
273
|
- If the user's intent is ambiguous — it could mean multiple things or lead to different screens — ask the user to clarify before navigating to the wrong place.
|
|
273
274
|
- NAVIGATION: Always use tap actions to move between screens — tap tab bar buttons, back buttons, and navigation links. This ensures all required route params are passed automatically by the app. The navigate() tool is ONLY for top-level screens that require no params (e.g. Login, Settings, Cart). NEVER call navigate() on screens that require a selection or ID (e.g. DishDetail, SelectCategory, ProfileDetail) — this will crash the app. For those screens, always tap the relevant item in the parent screen.
|
|
275
|
+
- UI SIMPLIFICATION: If you see elements labeled \`aiPriority="low"\` inside a specific \`zoneId=...\`, and the screen looks cluttered relative to the user's immediate goal, use the \`simplify_zone(zoneId)\` tool to hide those low-priority elements. Use \`restore_zone(zoneId)\` to bring them back if needed. The user does NOT need to explicitly ask for this — use your judgment based on their request.
|
|
274
276
|
</rules>
|
|
275
277
|
|
|
276
278
|
<capability>
|
package/src/core/types.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface InteractiveElement {
|
|
|
22
22
|
type: ElementType;
|
|
23
23
|
/** Human-readable label (extracted from Text children or accessibilityLabel) */
|
|
24
24
|
label: string;
|
|
25
|
+
/** Declarative AI priority explicitly set by the developer */
|
|
26
|
+
aiPriority?: 'high' | 'low';
|
|
27
|
+
/** The nearest enclosing AIZone ID (if any) */
|
|
28
|
+
zoneId?: string;
|
|
25
29
|
/** Reference to the Fiber node for execution */
|
|
26
30
|
fiberNode: any;
|
|
27
31
|
/**
|
|
@@ -216,6 +220,14 @@ export interface AgentConfig {
|
|
|
216
220
|
*/
|
|
217
221
|
onAskUser?: (question: string) => Promise<string>;
|
|
218
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Called immediately before and after each agent tool execution.
|
|
225
|
+
* Used by AIAgent to toggle isAgentActing on TelemetryService so that
|
|
226
|
+
* AI-driven taps are not double-counted as user interactions.
|
|
227
|
+
* @param active - true = agent is acting, false = agent finished acting
|
|
228
|
+
*/
|
|
229
|
+
onToolExecute?: (active: boolean) => void;
|
|
230
|
+
|
|
219
231
|
// ─── Expo Router Support ─────────────────────────────────────────────────
|
|
220
232
|
|
|
221
233
|
/**
|
|
@@ -417,3 +429,51 @@ export interface AIProvider {
|
|
|
417
429
|
screenshot?: string,
|
|
418
430
|
): Promise<ProviderResult>;
|
|
419
431
|
}
|
|
432
|
+
|
|
433
|
+
// ─── AI-Native UI (Pillar B) ───────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Configuration for an AIZone declarative boundary.
|
|
437
|
+
*/
|
|
438
|
+
export interface AIZoneConfig {
|
|
439
|
+
/** Unique identifier for this zone on the current screen */
|
|
440
|
+
id: string;
|
|
441
|
+
/** Whether the AI is allowed to use guide_user() to highlight elements here */
|
|
442
|
+
allowHighlight?: boolean;
|
|
443
|
+
/** Whether the AI is allowed to inject tooltip hints here */
|
|
444
|
+
allowInjectHint?: boolean;
|
|
445
|
+
/** Whether the AI is allowed to hide children marked with aiPriority="low" */
|
|
446
|
+
allowSimplify?: boolean;
|
|
447
|
+
/** Whether the AI is allowed to inject custom cards here */
|
|
448
|
+
allowInjectCard?: boolean;
|
|
449
|
+
/**
|
|
450
|
+
* Whitelist of React component templates the AI can instantiate.
|
|
451
|
+
* Required if allowInjectCard is true.
|
|
452
|
+
* IMPORTANT: The AI receives the displayName of these components
|
|
453
|
+
* and can only produce props for them. It cannot generate JSX.
|
|
454
|
+
*/
|
|
455
|
+
templates?: React.ComponentType<any>[];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Internal representation of a registered zone.
|
|
460
|
+
*/
|
|
461
|
+
export interface RegisteredZone extends AIZoneConfig {
|
|
462
|
+
/** React ref to the zone's container View */
|
|
463
|
+
ref: React.RefObject<any>;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export interface ProactiveHelpConfig {
|
|
467
|
+
/** Enable proactive help (default: true) */
|
|
468
|
+
enabled?: boolean;
|
|
469
|
+
/** Time in minutes before a subtle pulse (default: 2) */
|
|
470
|
+
pulseAfterMinutes?: number;
|
|
471
|
+
/** Time in minutes before showing a help badge (default: 4) */
|
|
472
|
+
badgeAfterMinutes?: number;
|
|
473
|
+
/** Default text for the badge (default: "Need help with this screen?") */
|
|
474
|
+
badgeText?: string;
|
|
475
|
+
/** If true, dismissing the badge disables proactive help for the rest of the session (default: true) */
|
|
476
|
+
dismissForSession?: boolean;
|
|
477
|
+
/** Dynamic context suggestion generator based on current screen */
|
|
478
|
+
generateSuggestion?: (screenName: string) => string;
|
|
479
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
// ─── Components ──────────────────────────────────────────────
|
|
9
9
|
export { AIAgent } from './components/AIAgent';
|
|
10
|
+
export { AIZone } from './components/AIZone';
|
|
11
|
+
// Built-in card templates for AIZone injection
|
|
12
|
+
// Note: displayName is set explicitly on each — required for minification-safe template lookup.
|
|
13
|
+
export { InfoCard } from './components/cards/InfoCard';
|
|
14
|
+
export { ReviewSummary } from './components/cards/ReviewSummary';
|
|
10
15
|
|
|
11
16
|
// ─── Providers ───────────────────────────────────────────────
|
|
12
17
|
export { GeminiProvider } from './providers/GeminiProvider';
|
|
@@ -22,6 +27,10 @@ export { AudioInputService } from './services/AudioInputService';
|
|
|
22
27
|
export { AudioOutputService } from './services/AudioOutputService';
|
|
23
28
|
export { KnowledgeBaseService } from './services/KnowledgeBaseService';
|
|
24
29
|
|
|
30
|
+
// ─── Analytics ───────────────────────────────────────────────
|
|
31
|
+
// Requires api.mobileai.dev — hidden until backend is live
|
|
32
|
+
// export { MobileAI } from './services/telemetry';
|
|
33
|
+
|
|
25
34
|
// ─── Utilities ───────────────────────────────────────────────
|
|
26
35
|
export { logger } from './utils/logger';
|
|
27
36
|
|
|
@@ -50,3 +59,25 @@ export type {
|
|
|
50
59
|
VoiceServiceCallbacks,
|
|
51
60
|
VoiceStatus,
|
|
52
61
|
} from './services/VoiceService';
|
|
62
|
+
|
|
63
|
+
// Requires api.mobileai.dev — hidden until backend is live
|
|
64
|
+
// export type {
|
|
65
|
+
// TelemetryConfig,
|
|
66
|
+
// TelemetryEvent,
|
|
67
|
+
// } from './services/telemetry';
|
|
68
|
+
|
|
69
|
+
// ─── Support Mode ────────────────────────────────────────────
|
|
70
|
+
// SupportGreeting, CSATSurvey, buildSupportPrompt work standalone (no backend)
|
|
71
|
+
// createEscalateTool works with provider='custom' (no backend)
|
|
72
|
+
// EscalationSocket and provider='mobileai' require api.mobileai.dev — hidden
|
|
73
|
+
export { SupportGreeting, CSATSurvey, buildSupportPrompt, createEscalateTool } from './support';
|
|
74
|
+
|
|
75
|
+
export type {
|
|
76
|
+
SupportModeConfig,
|
|
77
|
+
QuickReply,
|
|
78
|
+
EscalationConfig,
|
|
79
|
+
EscalationContext,
|
|
80
|
+
CSATConfig,
|
|
81
|
+
CSATRating,
|
|
82
|
+
BusinessHoursConfig,
|
|
83
|
+
} from './support';
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { logger } from '../utils/logger';
|
|
12
12
|
import { base64ToFloat32 } from '../utils/audioUtils';
|
|
13
|
+
import { NativeModules } from 'react-native';
|
|
13
14
|
|
|
14
15
|
// ─── Types ─────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -44,6 +45,18 @@ export class AudioOutputService {
|
|
|
44
45
|
try {
|
|
45
46
|
let audioApi: any;
|
|
46
47
|
try {
|
|
48
|
+
// Guard: NativeModules.AudioApiModule is only present in dev/prod builds.
|
|
49
|
+
// In Expo Go it is undefined, and require() throws a native bridge error
|
|
50
|
+
// that cannot be caught by a standard try/catch.
|
|
51
|
+
if (!NativeModules.AudioApiModule) {
|
|
52
|
+
const msg =
|
|
53
|
+
'[mobileai] react-native-audio-api native module not found. '
|
|
54
|
+
+ 'Voice audio output requires a development build (not Expo Go). '
|
|
55
|
+
+ 'Run: npx expo run:ios';
|
|
56
|
+
logger.warn('AudioOutput', msg);
|
|
57
|
+
this.config.onError?.(msg);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
47
60
|
const audioApiModule = ['react-native', 'audio-api'].join('-');
|
|
48
61
|
audioApi = require(audioApiModule);
|
|
49
62
|
} catch {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { logger } from '../../utils/logger';
|
|
2
|
+
import { getDeviceId } from '../telemetry/device';
|
|
3
|
+
|
|
4
|
+
const LOG_TAG = 'FlagService';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MurmurHash3 (32-bit) implementation
|
|
8
|
+
*/
|
|
9
|
+
function murmurhash3_32_gc(key: string, seed: number = 0): number {
|
|
10
|
+
let remainder, bytes, h1, h1b, c1, c2, k1, i;
|
|
11
|
+
|
|
12
|
+
remainder = key.length & 3; // key.length % 4
|
|
13
|
+
bytes = key.length - remainder;
|
|
14
|
+
h1 = seed;
|
|
15
|
+
c1 = 0xcc9e2d51;
|
|
16
|
+
c2 = 0x1b873593;
|
|
17
|
+
i = 0;
|
|
18
|
+
|
|
19
|
+
while (i < bytes) {
|
|
20
|
+
k1 =
|
|
21
|
+
((key.charCodeAt(i) & 0xff)) |
|
|
22
|
+
((key.charCodeAt(++i) & 0xff) << 8) |
|
|
23
|
+
((key.charCodeAt(++i) & 0xff) << 16) |
|
|
24
|
+
((key.charCodeAt(++i) & 0xff) << 24);
|
|
25
|
+
++i;
|
|
26
|
+
|
|
27
|
+
k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff;
|
|
28
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
29
|
+
k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff;
|
|
30
|
+
|
|
31
|
+
h1 ^= k1;
|
|
32
|
+
h1 = (h1 << 13) | (h1 >>> 19);
|
|
33
|
+
h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff;
|
|
34
|
+
h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (remainder >= 1) {
|
|
38
|
+
k1 = 0;
|
|
39
|
+
if (remainder >= 3) k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
|
|
40
|
+
if (remainder >= 2) k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
|
|
41
|
+
k1 ^= (key.charCodeAt(i) & 0xff);
|
|
42
|
+
k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff;
|
|
43
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
44
|
+
k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff;
|
|
45
|
+
h1 ^= k1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
h1 ^= key.length;
|
|
49
|
+
|
|
50
|
+
h1 ^= h1 >>> 16;
|
|
51
|
+
h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff;
|
|
52
|
+
h1 ^= h1 >>> 13;
|
|
53
|
+
h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff;
|
|
54
|
+
h1 ^= h1 >>> 16;
|
|
55
|
+
|
|
56
|
+
return h1 >>> 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FeatureFlagPayload {
|
|
60
|
+
key: string;
|
|
61
|
+
variants: string[];
|
|
62
|
+
rollout: number[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class FlagService {
|
|
66
|
+
private assignments: Record<string, string> = {};
|
|
67
|
+
private fetched: boolean = false;
|
|
68
|
+
|
|
69
|
+
constructor(private hostUrl: string) {}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fetch feature flags from the dashboard backend
|
|
73
|
+
*/
|
|
74
|
+
async fetch(analyticsKey: string, userId?: string): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
// Avoid fetching if already loaded, unless explicitly forced?
|
|
77
|
+
// For now, allow refetching just in case.
|
|
78
|
+
const res = await fetch(`${this.hostUrl}/api/v1/flags/sync?key=${analyticsKey}`);
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
throw new Error(`Failed to fetch flags: ${res.status}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const data = await res.json();
|
|
84
|
+
const flags: FeatureFlagPayload[] = data.flags || [];
|
|
85
|
+
|
|
86
|
+
this.assignAll(flags, userId);
|
|
87
|
+
this.fetched = true;
|
|
88
|
+
logger.info(LOG_TAG, `Fetched ${flags.length} flags`);
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
logger.warn(LOG_TAG, `Could not sync feature flags: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Deterministically assign a variant using murmurhash.
|
|
96
|
+
*/
|
|
97
|
+
private assignVariant(userIdentifier: string, flagKey: string, variants: string[], rollout: number[]): string {
|
|
98
|
+
const hash = murmurhash3_32_gc(`${userIdentifier}_${flagKey}`) % 100;
|
|
99
|
+
let cumulative = 0;
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < rollout.length; i++) {
|
|
102
|
+
cumulative += rollout[i]!;
|
|
103
|
+
if (hash < cumulative) {
|
|
104
|
+
return variants[i]!;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback if rollout doesn't equal exactly 100 or edge case
|
|
109
|
+
return variants[0]!
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private assignAll(flags: FeatureFlagPayload[], userId?: string) {
|
|
113
|
+
const identifier = userId || getDeviceId();
|
|
114
|
+
|
|
115
|
+
const newAssignments: Record<string, string> = {};
|
|
116
|
+
for (const flag of flags) {
|
|
117
|
+
if (flag.variants.length > 0 && flag.rollout.length === flag.variants.length) {
|
|
118
|
+
newAssignments[flag.key] = this.assignVariant(identifier, flag.key, flag.variants, flag.rollout);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.assignments = newAssignments;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Get a specific flag value */
|
|
126
|
+
getFlag(key: string, defaultValue?: string): string {
|
|
127
|
+
if (!this.fetched) {
|
|
128
|
+
logger.debug(LOG_TAG, `getFlag("${key}") called before flags were fetched. Returning default.`);
|
|
129
|
+
}
|
|
130
|
+
return this.assignments[key] ?? defaultValue ?? '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Get all active assignments for telemetry */
|
|
134
|
+
getAllFlags(): Record<string, string> {
|
|
135
|
+
return { ...this.assignments };
|
|
136
|
+
}
|
|
137
|
+
}
|