@mobileai/react-native 0.9.18 → 0.9.19
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 +28 -20
- package/MobileAIFloatingOverlay.podspec +25 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/mobileai/overlay/FloatingOverlayView.kt +151 -0
- package/android/src/main/java/com/mobileai/overlay/MobileAIOverlayPackage.kt +23 -0
- package/android/src/newarch/com/mobileai/overlay/FloatingOverlayViewManager.kt +45 -0
- package/android/src/oldarch/com/mobileai/overlay/FloatingOverlayViewManager.kt +29 -0
- package/ios/MobileAIFloatingOverlayComponentView.mm +73 -0
- package/lib/module/components/AIAgent.js +902 -136
- package/lib/module/components/AIConsentDialog.js +439 -0
- package/lib/module/components/AgentChatBar.js +828 -134
- package/lib/module/components/AgentOverlay.js +2 -1
- package/lib/module/components/DiscoveryTooltip.js +21 -9
- package/lib/module/components/FloatingOverlayWrapper.js +108 -0
- package/lib/module/components/Icons.js +123 -0
- package/lib/module/config/endpoints.js +12 -2
- package/lib/module/core/AgentRuntime.js +373 -27
- package/lib/module/core/FiberAdapter.js +56 -0
- package/lib/module/core/FiberTreeWalker.js +186 -80
- package/lib/module/core/IdleDetector.js +19 -0
- package/lib/module/core/NativeAlertInterceptor.js +191 -0
- package/lib/module/core/systemPrompt.js +203 -45
- package/lib/module/index.js +3 -0
- package/lib/module/providers/GeminiProvider.js +72 -56
- package/lib/module/providers/ProviderFactory.js +6 -2
- package/lib/module/services/AudioInputService.js +3 -12
- package/lib/module/services/AudioOutputService.js +1 -13
- package/lib/module/services/ConversationService.js +166 -0
- package/lib/module/services/MobileAIKnowledgeRetriever.js +41 -0
- package/lib/module/services/VoiceService.js +29 -8
- package/lib/module/services/telemetry/MobileAI.js +44 -0
- package/lib/module/services/telemetry/TelemetryService.js +13 -1
- package/lib/module/services/telemetry/TouchAutoCapture.js +44 -18
- package/lib/module/specs/FloatingOverlayNativeComponent.ts +19 -0
- package/lib/module/support/CSATSurvey.js +95 -12
- package/lib/module/support/EscalationSocket.js +70 -1
- package/lib/module/support/ReportedIssueEventSource.js +148 -0
- package/lib/module/support/escalateTool.js +4 -2
- package/lib/module/support/index.js +1 -0
- package/lib/module/support/reportIssueTool.js +127 -0
- package/lib/module/support/supportPrompt.js +77 -9
- package/lib/module/tools/guideTool.js +2 -1
- package/lib/module/tools/longPressTool.js +4 -3
- package/lib/module/tools/pickerTool.js +6 -4
- package/lib/module/tools/tapTool.js +12 -3
- package/lib/module/tools/typeTool.js +19 -10
- package/lib/module/utils/logger.js +175 -6
- package/lib/typescript/react-native.config.d.ts +11 -0
- package/lib/typescript/src/components/AIAgent.d.ts +28 -2
- package/lib/typescript/src/components/AIConsentDialog.d.ts +153 -0
- package/lib/typescript/src/components/AgentChatBar.d.ts +15 -2
- package/lib/typescript/src/components/DiscoveryTooltip.d.ts +3 -1
- package/lib/typescript/src/components/FloatingOverlayWrapper.d.ts +51 -0
- package/lib/typescript/src/components/Icons.d.ts +8 -0
- package/lib/typescript/src/config/endpoints.d.ts +5 -3
- package/lib/typescript/src/core/AgentRuntime.d.ts +4 -0
- package/lib/typescript/src/core/FiberAdapter.d.ts +25 -0
- package/lib/typescript/src/core/FiberTreeWalker.d.ts +2 -0
- package/lib/typescript/src/core/IdleDetector.d.ts +11 -0
- package/lib/typescript/src/core/NativeAlertInterceptor.d.ts +55 -0
- package/lib/typescript/src/core/types.d.ts +106 -1
- package/lib/typescript/src/index.d.ts +9 -4
- package/lib/typescript/src/providers/GeminiProvider.d.ts +6 -5
- package/lib/typescript/src/services/ConversationService.d.ts +55 -0
- package/lib/typescript/src/services/MobileAIKnowledgeRetriever.d.ts +9 -0
- package/lib/typescript/src/services/telemetry/MobileAI.d.ts +7 -0
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +1 -1
- package/lib/typescript/src/services/telemetry/TouchAutoCapture.d.ts +9 -6
- package/lib/typescript/src/services/telemetry/types.d.ts +3 -1
- package/lib/typescript/src/specs/FloatingOverlayNativeComponent.d.ts +17 -0
- package/lib/typescript/src/support/EscalationSocket.d.ts +17 -0
- package/lib/typescript/src/support/ReportedIssueEventSource.d.ts +24 -0
- package/lib/typescript/src/support/escalateTool.d.ts +5 -0
- package/lib/typescript/src/support/index.d.ts +2 -1
- package/lib/typescript/src/support/reportIssueTool.d.ts +20 -0
- package/lib/typescript/src/support/types.d.ts +56 -1
- package/lib/typescript/src/utils/logger.d.ts +15 -0
- package/package.json +19 -5
- package/react-native.config.js +12 -0
|
@@ -17,6 +17,8 @@ import { createProvider } from "../providers/ProviderFactory.js";
|
|
|
17
17
|
import { AgentContext } from "../hooks/useAction.js";
|
|
18
18
|
import { AgentChatBar } from "./AgentChatBar.js";
|
|
19
19
|
import { AgentOverlay } from "./AgentOverlay.js";
|
|
20
|
+
import { AIConsentDialog, useAIConsent } from "./AIConsentDialog.js";
|
|
21
|
+
import { FloatingOverlayWrapper } from "./FloatingOverlayWrapper.js";
|
|
20
22
|
import { logger } from "../utils/logger.js";
|
|
21
23
|
import { buildVoiceSystemPrompt } from "../core/systemPrompt.js";
|
|
22
24
|
import { MCPBridge } from "../core/MCPBridge.js";
|
|
@@ -31,10 +33,14 @@ import { HighlightOverlay } from "./HighlightOverlay.js";
|
|
|
31
33
|
import { IdleDetector } from "../core/IdleDetector.js";
|
|
32
34
|
import { ProactiveHint } from "./ProactiveHint.js";
|
|
33
35
|
import { createEscalateTool } from "../support/escalateTool.js";
|
|
36
|
+
import { createReportIssueTool } from "../support/reportIssueTool.js";
|
|
34
37
|
import { EscalationSocket } from "../support/EscalationSocket.js";
|
|
35
38
|
import { EscalationEventSource } from "../support/EscalationEventSource.js";
|
|
39
|
+
import { ReportedIssueEventSource } from "../support/ReportedIssueEventSource.js";
|
|
36
40
|
import { SupportChatModal } from "../support/SupportChatModal.js";
|
|
37
41
|
import { ENDPOINTS } from "../config/endpoints.js";
|
|
42
|
+
import * as ConversationService from "../services/ConversationService.js";
|
|
43
|
+
import { createMobileAIKnowledgeRetriever } from "../services/MobileAIKnowledgeRetriever.js";
|
|
38
44
|
|
|
39
45
|
// ─── Context ───────────────────────────────────────────────────
|
|
40
46
|
|
|
@@ -114,13 +120,32 @@ export function AIAgent({
|
|
|
114
120
|
pushToken,
|
|
115
121
|
pushTokenType,
|
|
116
122
|
interactionMode,
|
|
117
|
-
showDiscoveryTooltip: showDiscoveryTooltipProp = true
|
|
123
|
+
showDiscoveryTooltip: showDiscoveryTooltipProp = true,
|
|
124
|
+
discoveryTooltipMessage,
|
|
125
|
+
customerSuccess,
|
|
126
|
+
onboarding,
|
|
127
|
+
consent
|
|
118
128
|
}) {
|
|
129
|
+
// Consent is ALWAYS required by default — only disabled if explicitly set to false.
|
|
130
|
+
// No consent prop at all → gate is active with default dialog config.
|
|
131
|
+
const consentRequired = consent?.required !== false;
|
|
132
|
+
const consentConfig = consent ?? {
|
|
133
|
+
required: true,
|
|
134
|
+
persist: false
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ─── AI Consent State (Apple Guideline 5.1.2(i)) ─────────────
|
|
138
|
+
const [hasConsented, grantConsent,, isConsentLoading] = useAIConsent(consentConfig.persist);
|
|
139
|
+
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
|
140
|
+
const pendingConsentSendRef = useRef(null);
|
|
141
|
+
const pendingFollowUpAfterApprovalRef = useRef(null);
|
|
142
|
+
const consentGateActive = consentRequired && !hasConsented && !isConsentLoading;
|
|
119
143
|
// Configure logger based on debug prop
|
|
120
144
|
React.useEffect(() => {
|
|
121
145
|
logger.setEnabled(debug);
|
|
122
146
|
if (debug) {
|
|
123
147
|
logger.info('AIAgent', '🔧 Debug logging enabled');
|
|
148
|
+
logger.info('AIAgent', `⚙️ Initial config: interactionMode=${interactionMode || 'copilot(default)'} enableVoice=${enableVoice} useScreenMap=${useScreenMap} analytics=${!!analyticsKey}`);
|
|
124
149
|
}
|
|
125
150
|
}, [debug]);
|
|
126
151
|
const rootViewRef = useRef(null);
|
|
@@ -129,7 +154,22 @@ export function AIAgent({
|
|
|
129
154
|
const [lastResult, setLastResult] = useState(null);
|
|
130
155
|
const [lastUserMessage, setLastUserMessage] = useState(null);
|
|
131
156
|
const [messages, setMessages] = useState([]);
|
|
157
|
+
const [supportMessages, setSupportMessages] = useState([]);
|
|
132
158
|
const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
|
|
159
|
+
// Mirror of messages for safe reading inside async callbacks (avoids setMessages abuse)
|
|
160
|
+
const messagesRef = useRef([]);
|
|
161
|
+
|
|
162
|
+
// ── Conversation History State ────────────────────────────────
|
|
163
|
+
const [conversations, setConversations] = useState([]);
|
|
164
|
+
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
|
165
|
+
const activeConversationIdRef = useRef(null);
|
|
166
|
+
const lastSavedMessageCountRef = useRef(0);
|
|
167
|
+
const appendDebounceRef = useRef(null);
|
|
168
|
+
|
|
169
|
+
// Keep messagesRef always in sync — used by async save callbacks
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
messagesRef.current = messages;
|
|
172
|
+
}, [messages]);
|
|
133
173
|
|
|
134
174
|
// Increment scroll trigger when messages change to auto-scroll chat modal
|
|
135
175
|
useEffect(() => {
|
|
@@ -145,6 +185,7 @@ export function AIAgent({
|
|
|
145
185
|
const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
|
|
146
186
|
const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
|
|
147
187
|
const [unreadCounts, setUnreadCounts] = useState({});
|
|
188
|
+
const seenReportedIssueUpdatesRef = useRef(new Set());
|
|
148
189
|
// Ref mirrors selectedTicketId — lets socket callbacks access current value
|
|
149
190
|
// without stale closures (sockets are long-lived, closures capture old state).
|
|
150
191
|
const selectedTicketIdRef = useRef(null);
|
|
@@ -157,6 +198,48 @@ export function AIAgent({
|
|
|
157
198
|
// SSE connections per ticket — reliable fallback for ticket_closed events
|
|
158
199
|
// when the WebSocket is disconnected. EventSource auto-reconnects.
|
|
159
200
|
const sseRef = useRef(new Map());
|
|
201
|
+
const reportedIssuesSSERef = useRef(null);
|
|
202
|
+
const agentFrtFiredRef = useRef(false);
|
|
203
|
+
const humanFrtFiredRef = useRef({});
|
|
204
|
+
|
|
205
|
+
// ── Onboarding Journey State ────────────────────────────────
|
|
206
|
+
const [isOnboardingActive, setIsOnboardingActive] = useState(false);
|
|
207
|
+
const [currentOnboardingIndex, setCurrentOnboardingIndex] = useState(0);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!onboarding?.enabled) return;
|
|
210
|
+
if (onboarding.firstLaunchOnly !== false) {
|
|
211
|
+
void (async () => {
|
|
212
|
+
try {
|
|
213
|
+
const AS = getTooltipStorage();
|
|
214
|
+
if (!AS) {
|
|
215
|
+
setIsOnboardingActive(true);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const completed = await AS.getItem('@mobileai_onboarding_completed');
|
|
219
|
+
if (!completed) setIsOnboardingActive(true);
|
|
220
|
+
} catch {
|
|
221
|
+
setIsOnboardingActive(true);
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
} else {
|
|
225
|
+
setIsOnboardingActive(true);
|
|
226
|
+
}
|
|
227
|
+
}, [onboarding?.enabled, onboarding?.firstLaunchOnly]);
|
|
228
|
+
const advanceOnboarding = useCallback(() => {
|
|
229
|
+
if (!onboarding?.steps) return;
|
|
230
|
+
if (currentOnboardingIndex >= onboarding.steps.length - 1) {
|
|
231
|
+
setIsOnboardingActive(false);
|
|
232
|
+
onboarding.onComplete?.();
|
|
233
|
+
void (async () => {
|
|
234
|
+
try {
|
|
235
|
+
const AS = getTooltipStorage();
|
|
236
|
+
await AS?.setItem('@mobileai_onboarding_completed', 'true');
|
|
237
|
+
} catch {/* graceful */}
|
|
238
|
+
})();
|
|
239
|
+
} else {
|
|
240
|
+
setCurrentOnboardingIndex(prev => prev + 1);
|
|
241
|
+
}
|
|
242
|
+
}, [onboarding, currentOnboardingIndex]);
|
|
160
243
|
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
|
161
244
|
|
|
162
245
|
// ── Discovery Tooltip (one-time) ──────────────────────────
|
|
@@ -277,6 +360,31 @@ export function AIAgent({
|
|
|
277
360
|
setMessages([]);
|
|
278
361
|
setLastResult(null);
|
|
279
362
|
}, []);
|
|
363
|
+
const applyReportedIssueUpdates = useCallback((nextIssues, options) => {
|
|
364
|
+
const replayToChat = options?.replayToChat ?? true;
|
|
365
|
+
nextIssues.forEach(issue => {
|
|
366
|
+
const history = Array.isArray(issue.statusHistory) ? issue.statusHistory : [];
|
|
367
|
+
history.forEach(entry => {
|
|
368
|
+
if (!entry || typeof entry !== 'object') return;
|
|
369
|
+
const id = typeof entry.id === 'string' ? entry.id : null;
|
|
370
|
+
const message = typeof entry.message === 'string' ? entry.message : null;
|
|
371
|
+
if (!id || !message) return;
|
|
372
|
+
if (seenReportedIssueUpdatesRef.current.has(id)) return;
|
|
373
|
+
seenReportedIssueUpdatesRef.current.add(id);
|
|
374
|
+
if (!replayToChat) return;
|
|
375
|
+
const entryTimestamp = typeof entry.timestamp === 'string' ? new Date(entry.timestamp).getTime() : NaN;
|
|
376
|
+
setMessages(prev => {
|
|
377
|
+
if (prev.some(msg => msg.id === `reported-${id}`)) return prev;
|
|
378
|
+
return [...prev, {
|
|
379
|
+
id: `reported-${id}`,
|
|
380
|
+
role: 'assistant',
|
|
381
|
+
content: message,
|
|
382
|
+
timestamp: Number.isFinite(entryTimestamp) ? entryTimestamp : Date.now()
|
|
383
|
+
}];
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}, []);
|
|
280
388
|
const getResolvedScreenName = useCallback(() => {
|
|
281
389
|
const routeName = navRef?.getCurrentRoute?.()?.name;
|
|
282
390
|
if (typeof routeName === 'string' && routeName.trim().length > 0) {
|
|
@@ -288,6 +396,15 @@ export function AIAgent({
|
|
|
288
396
|
}
|
|
289
397
|
return 'unknown';
|
|
290
398
|
}, [navRef]);
|
|
399
|
+
const resolvedKnowledgeBase = useMemo(() => {
|
|
400
|
+
if (knowledgeBase) return knowledgeBase;
|
|
401
|
+
if (!analyticsKey) return undefined;
|
|
402
|
+
return createMobileAIKnowledgeRetriever({
|
|
403
|
+
publishableKey: analyticsKey,
|
|
404
|
+
baseUrl: analyticsProxyUrl ?? ENDPOINTS.escalation,
|
|
405
|
+
headers: analyticsProxyHeaders
|
|
406
|
+
});
|
|
407
|
+
}, [analyticsKey, analyticsProxyHeaders, analyticsProxyUrl, knowledgeBase]);
|
|
291
408
|
|
|
292
409
|
// ─── Auto-create MobileAI escalation tool ─────────────────────
|
|
293
410
|
// When analyticsKey is present and consumer hasn't provided their own
|
|
@@ -310,6 +427,19 @@ export function AIAgent({
|
|
|
310
427
|
role: m.role,
|
|
311
428
|
content: m.content
|
|
312
429
|
})),
|
|
430
|
+
getToolCalls: () => {
|
|
431
|
+
const toolCalls = [];
|
|
432
|
+
messages.forEach(m => {
|
|
433
|
+
if (m.result?.steps) {
|
|
434
|
+
m.result.steps.forEach(step => {
|
|
435
|
+
if (step.action && step.action.name !== 'done' && step.action.name !== 'agent_step' && step.action.name !== 'escalate_to_human') {
|
|
436
|
+
toolCalls.push(step.action);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
return toolCalls;
|
|
442
|
+
},
|
|
313
443
|
getScreenFlow: () => telemetryRef.current?.getScreenFlow() ?? [],
|
|
314
444
|
userContext,
|
|
315
445
|
pushToken,
|
|
@@ -372,6 +502,13 @@ export function AIAgent({
|
|
|
372
502
|
},
|
|
373
503
|
onHumanReply: (reply, ticketId) => {
|
|
374
504
|
if (ticketId) {
|
|
505
|
+
if (!humanFrtFiredRef.current[ticketId]) {
|
|
506
|
+
humanFrtFiredRef.current[ticketId] = true;
|
|
507
|
+
telemetryRef.current?.track('human_first_response', {
|
|
508
|
+
ticketId
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
375
512
|
// Always update the ticket's history (source of truth for ticket cards)
|
|
376
513
|
setTickets(prev => prev.map(t => {
|
|
377
514
|
if (t.id !== ticketId) return t;
|
|
@@ -393,7 +530,7 @@ export function AIAgent({
|
|
|
393
530
|
content: reply,
|
|
394
531
|
timestamp: Date.now()
|
|
395
532
|
};
|
|
396
|
-
|
|
533
|
+
setSupportMessages(prev => [...prev, humanMsg]);
|
|
397
534
|
setLastResult({
|
|
398
535
|
success: true,
|
|
399
536
|
message: `👤 ${reply}`,
|
|
@@ -427,6 +564,43 @@ export function AIAgent({
|
|
|
427
564
|
});
|
|
428
565
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
429
566
|
}, [analyticsKey, customTools, getResolvedScreenName, navRef, openSSE, userContext, pushToken, pushTokenType, messages, clearSupport]);
|
|
567
|
+
const autoReportIssueTool = useMemo(() => {
|
|
568
|
+
if (!analyticsKey) return null;
|
|
569
|
+
if (customTools?.['report_issue']) return null;
|
|
570
|
+
return createReportIssueTool({
|
|
571
|
+
analyticsKey,
|
|
572
|
+
getCurrentScreen: getResolvedScreenName,
|
|
573
|
+
getHistory: () => messages.map(m => ({
|
|
574
|
+
role: m.role,
|
|
575
|
+
content: m.content
|
|
576
|
+
})),
|
|
577
|
+
getScreenFlow: () => telemetryRef.current?.getScreenFlow() ?? [],
|
|
578
|
+
userContext
|
|
579
|
+
});
|
|
580
|
+
}, [analyticsKey, customTools, getResolvedScreenName, messages, userContext]);
|
|
581
|
+
|
|
582
|
+
// ─── Load conversation history on mount ─────────────────────────
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (!analyticsKey) return;
|
|
585
|
+
void (async () => {
|
|
586
|
+
try {
|
|
587
|
+
setIsLoadingHistory(true);
|
|
588
|
+
await initDeviceId();
|
|
589
|
+
const deviceId = getDeviceId();
|
|
590
|
+
const list = await ConversationService.fetchConversations({
|
|
591
|
+
analyticsKey,
|
|
592
|
+
userId: userContext?.userId,
|
|
593
|
+
deviceId: deviceId || undefined
|
|
594
|
+
});
|
|
595
|
+
setConversations(list);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
logger.warn('AIAgent', 'Failed to load conversation history:', err);
|
|
598
|
+
} finally {
|
|
599
|
+
setIsLoadingHistory(false);
|
|
600
|
+
}
|
|
601
|
+
})();
|
|
602
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
603
|
+
}, [analyticsKey, userContext?.userId]);
|
|
430
604
|
|
|
431
605
|
// ─── Restore pending tickets on app start ──────────────────────
|
|
432
606
|
useEffect(() => {
|
|
@@ -531,7 +705,11 @@ export function AIAgent({
|
|
|
531
705
|
onTicketClosed: () => clearSupport(ticket.id),
|
|
532
706
|
onError: err => logger.error('AIAgent', '★ Restored socket error:', err)
|
|
533
707
|
});
|
|
534
|
-
|
|
708
|
+
if (ticket.wsUrl) {
|
|
709
|
+
socket.connect(ticket.wsUrl);
|
|
710
|
+
} else {
|
|
711
|
+
logger.warn('AIAgent', '★ Restored ticket has no wsUrl — skipping socket connect:', ticket.id);
|
|
712
|
+
}
|
|
535
713
|
// Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
|
|
536
714
|
pendingSocketsRef.current.set(ticket.id, socket);
|
|
537
715
|
logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
|
|
@@ -542,6 +720,61 @@ export function AIAgent({
|
|
|
542
720
|
})();
|
|
543
721
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
544
722
|
}, [analyticsKey]);
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
if (!analyticsKey) return;
|
|
725
|
+
let isCancelled = false;
|
|
726
|
+
const syncIssues = async () => {
|
|
727
|
+
try {
|
|
728
|
+
await initDeviceId();
|
|
729
|
+
const deviceId = getDeviceId();
|
|
730
|
+
if (!userContext?.userId && !deviceId) return;
|
|
731
|
+
const query = new URLSearchParams({
|
|
732
|
+
analyticsKey
|
|
733
|
+
});
|
|
734
|
+
if (userContext?.userId) query.append('userId', userContext.userId);
|
|
735
|
+
if (deviceId) query.append('deviceId', deviceId);
|
|
736
|
+
const res = await fetch(`${ENDPOINTS.escalation}/api/v1/reported-issues/mine?${query.toString()}`);
|
|
737
|
+
if (!res.ok || isCancelled) return;
|
|
738
|
+
const data = await res.json();
|
|
739
|
+
const nextIssues = Array.isArray(data.issues) ? data.issues : [];
|
|
740
|
+
applyReportedIssueUpdates(nextIssues, {
|
|
741
|
+
replayToChat: false
|
|
742
|
+
});
|
|
743
|
+
} catch (error) {
|
|
744
|
+
logger.warn('AIAgent', 'Failed to sync reported issues:', error);
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
void syncIssues();
|
|
748
|
+
void (async () => {
|
|
749
|
+
await initDeviceId();
|
|
750
|
+
const deviceId = getDeviceId();
|
|
751
|
+
if (!userContext?.userId && !deviceId) return;
|
|
752
|
+
const query = new URLSearchParams({
|
|
753
|
+
analyticsKey
|
|
754
|
+
});
|
|
755
|
+
if (userContext?.userId) query.append('userId', userContext.userId);
|
|
756
|
+
if (deviceId) query.append('deviceId', deviceId);
|
|
757
|
+
reportedIssuesSSERef.current?.disconnect();
|
|
758
|
+
const sse = new ReportedIssueEventSource({
|
|
759
|
+
url: `${ENDPOINTS.escalation}/api/v1/reported-issues/events?${query.toString()}`,
|
|
760
|
+
onIssueUpdate: issue => {
|
|
761
|
+
applyReportedIssueUpdates([issue], {
|
|
762
|
+
replayToChat: true
|
|
763
|
+
});
|
|
764
|
+
},
|
|
765
|
+
onError: error => {
|
|
766
|
+
logger.warn('AIAgent', 'Reported issue SSE error:', error.message);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
sse.connect();
|
|
770
|
+
reportedIssuesSSERef.current = sse;
|
|
771
|
+
})();
|
|
772
|
+
return () => {
|
|
773
|
+
isCancelled = true;
|
|
774
|
+
reportedIssuesSSERef.current?.disconnect();
|
|
775
|
+
reportedIssuesSSERef.current = null;
|
|
776
|
+
};
|
|
777
|
+
}, [analyticsKey, applyReportedIssueUpdates, userContext?.userId]);
|
|
545
778
|
|
|
546
779
|
// ─── Ticket selection handlers ────────────────────────────────
|
|
547
780
|
const handleTicketSelect = useCallback(async ticketId => {
|
|
@@ -582,6 +815,11 @@ export function AIAgent({
|
|
|
582
815
|
// Trigger scroll to bottom when modal opens
|
|
583
816
|
setChatScrollTrigger(prev => prev + 1);
|
|
584
817
|
|
|
818
|
+
// Capture the fresh wsUrl returned by the server — it is the canonical value.
|
|
819
|
+
// The local `ticket` snapshot may have an empty wsUrl if it was a placeholder
|
|
820
|
+
// created before the WS URL was known (e.g. via onEscalationStarted).
|
|
821
|
+
let freshWsUrl = ticket.wsUrl;
|
|
822
|
+
|
|
585
823
|
// Fetch latest history from server — this is the source of truth and catches
|
|
586
824
|
// any messages that arrived while the socket was disconnected (modal closed,
|
|
587
825
|
// app backgrounded, etc.)
|
|
@@ -589,6 +827,9 @@ export function AIAgent({
|
|
|
589
827
|
const res = await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`);
|
|
590
828
|
if (res.ok) {
|
|
591
829
|
const data = await res.json();
|
|
830
|
+
if (data.wsUrl) {
|
|
831
|
+
freshWsUrl = data.wsUrl; // always prefer the live server value
|
|
832
|
+
}
|
|
592
833
|
const history = Array.isArray(data.history) ? data.history : [];
|
|
593
834
|
const restored = history.map((entry, i) => ({
|
|
594
835
|
id: `restored-${ticketId}-${i}`,
|
|
@@ -596,8 +837,8 @@ export function AIAgent({
|
|
|
596
837
|
content: entry.content,
|
|
597
838
|
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
598
839
|
}));
|
|
599
|
-
|
|
600
|
-
// Update ticket in local list with fresh history
|
|
840
|
+
setSupportMessages(restored);
|
|
841
|
+
// Update ticket in local list with fresh history + wsUrl
|
|
601
842
|
if (data.wsUrl) {
|
|
602
843
|
setTickets(prev => prev.map(t => t.id === ticketId ? {
|
|
603
844
|
...t,
|
|
@@ -614,9 +855,9 @@ export function AIAgent({
|
|
|
614
855
|
content: entry.content,
|
|
615
856
|
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
616
857
|
}));
|
|
617
|
-
|
|
858
|
+
setSupportMessages(restored);
|
|
618
859
|
} else {
|
|
619
|
-
|
|
860
|
+
setSupportMessages([]);
|
|
620
861
|
}
|
|
621
862
|
}
|
|
622
863
|
} catch (err) {
|
|
@@ -628,9 +869,9 @@ export function AIAgent({
|
|
|
628
869
|
content: entry.content,
|
|
629
870
|
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
|
|
630
871
|
}));
|
|
631
|
-
|
|
872
|
+
setSupportMessages(restored);
|
|
632
873
|
} else {
|
|
633
|
-
|
|
874
|
+
setSupportMessages([]);
|
|
634
875
|
}
|
|
635
876
|
}
|
|
636
877
|
|
|
@@ -638,10 +879,26 @@ export function AIAgent({
|
|
|
638
879
|
// otherwise create a fresh connection from the ticket's stored wsUrl.
|
|
639
880
|
const cached = pendingSocketsRef.current.get(ticketId);
|
|
640
881
|
if (cached) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
882
|
+
// If the socket errored (not just cleanly disconnected), discard it and
|
|
883
|
+
// fall through to create a fresh one — reusing an errored socket causes
|
|
884
|
+
// sendText to silently return false → "Connection lost" on every message.
|
|
885
|
+
if (cached.hasErrored) {
|
|
886
|
+
logger.warn('AIAgent', '★ Cached socket errored — discarding and creating fresh socket for ticket:', ticketId);
|
|
887
|
+
cached.disconnect();
|
|
888
|
+
pendingSocketsRef.current.delete(ticketId);
|
|
889
|
+
// Fall through to fresh socket creation below
|
|
890
|
+
} else {
|
|
891
|
+
pendingSocketsRef.current.delete(ticketId);
|
|
892
|
+
// If the cached socket was created before wsUrl was available (e.g. during
|
|
893
|
+
// on-mount restore), it may never have connected. Reconnect it now.
|
|
894
|
+
if (!cached.isConnected && freshWsUrl) {
|
|
895
|
+
logger.info('AIAgent', '★ Cached socket not connected — reconnecting with wsUrl:', freshWsUrl);
|
|
896
|
+
cached.connect(freshWsUrl);
|
|
897
|
+
}
|
|
898
|
+
setSupportSocket(cached);
|
|
899
|
+
logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
645
902
|
}
|
|
646
903
|
const socket = new EscalationSocket({
|
|
647
904
|
onReply: reply => {
|
|
@@ -666,7 +923,7 @@ export function AIAgent({
|
|
|
666
923
|
content: reply,
|
|
667
924
|
timestamp: Date.now()
|
|
668
925
|
};
|
|
669
|
-
|
|
926
|
+
setSupportMessages(prev => [...prev, msg]);
|
|
670
927
|
setLastResult({
|
|
671
928
|
success: true,
|
|
672
929
|
message: `👤 ${reply}`,
|
|
@@ -694,7 +951,11 @@ export function AIAgent({
|
|
|
694
951
|
},
|
|
695
952
|
onError: err => logger.error('AIAgent', '★ Socket error on select:', err)
|
|
696
953
|
});
|
|
697
|
-
|
|
954
|
+
if (freshWsUrl) {
|
|
955
|
+
socket.connect(freshWsUrl);
|
|
956
|
+
} else {
|
|
957
|
+
logger.warn('AIAgent', '★ Ticket has no wsUrl — skipping socket connect for ticket:', ticketId);
|
|
958
|
+
}
|
|
698
959
|
setSupportSocket(socket);
|
|
699
960
|
}, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
|
|
700
961
|
const handleBackToTickets = useCallback(() => {
|
|
@@ -709,18 +970,23 @@ export function AIAgent({
|
|
|
709
970
|
}
|
|
710
971
|
return null;
|
|
711
972
|
});
|
|
973
|
+
logger.info('AIAgent', '★ Back to tickets');
|
|
712
974
|
setSelectedTicketId(null);
|
|
713
|
-
|
|
975
|
+
setSupportMessages([]);
|
|
714
976
|
setIsLiveAgentTyping(false);
|
|
715
977
|
}, []); // No dependencies — uses refs/functional setters
|
|
716
978
|
|
|
717
979
|
const mergedCustomTools = useMemo(() => {
|
|
718
|
-
if (!autoEscalateTool) return customTools;
|
|
719
980
|
return {
|
|
720
|
-
|
|
981
|
+
...(autoEscalateTool ? {
|
|
982
|
+
escalate_to_human: autoEscalateTool
|
|
983
|
+
} : {}),
|
|
984
|
+
...(autoReportIssueTool ? {
|
|
985
|
+
report_issue: autoReportIssueTool
|
|
986
|
+
} : {}),
|
|
721
987
|
...customTools
|
|
722
988
|
};
|
|
723
|
-
}, [autoEscalateTool, customTools]);
|
|
989
|
+
}, [autoEscalateTool, autoReportIssueTool, customTools]);
|
|
724
990
|
|
|
725
991
|
// ─── Voice/Live Mode State ──────────────────────────────────
|
|
726
992
|
const [mode, setMode] = useState('text');
|
|
@@ -746,6 +1012,17 @@ export function AIAgent({
|
|
|
746
1012
|
|
|
747
1013
|
// Ref-based resolver for ask_user — stays alive across renders
|
|
748
1014
|
const askUserResolverRef = useRef(null);
|
|
1015
|
+
const pendingAskUserKindRef = useRef(null);
|
|
1016
|
+
// Tracks whether we're waiting for a BUTTON tap (not just any text answer).
|
|
1017
|
+
// Set true when kind='approval' is issued; cleared ONLY on actual button tap.
|
|
1018
|
+
// Forces kind='approval' on all subsequent ask_user calls until resolved.
|
|
1019
|
+
const pendingAppApprovalRef = useRef(false);
|
|
1020
|
+
// Stores a message typed by the user while the agent is still thinking (mid-approval flow).
|
|
1021
|
+
// Auto-resolved into the next ask_user call to prevent the message being lost.
|
|
1022
|
+
const queuedApprovalAnswerRef = useRef(null);
|
|
1023
|
+
const [pendingApprovalQuestion, setPendingApprovalQuestion] = useState(null);
|
|
1024
|
+
const overlayVisible = isThinking || !!pendingApprovalQuestion;
|
|
1025
|
+
const overlayStatusText = pendingApprovalQuestion ? 'Waiting for your approval...' : statusText;
|
|
749
1026
|
|
|
750
1027
|
// ─── Create Runtime ──────────────────────────────────────────
|
|
751
1028
|
|
|
@@ -775,7 +1052,7 @@ export function AIAgent({
|
|
|
775
1052
|
pathname,
|
|
776
1053
|
onStatusUpdate: setStatusText,
|
|
777
1054
|
onTokenUsage,
|
|
778
|
-
knowledgeBase,
|
|
1055
|
+
knowledgeBase: resolvedKnowledgeBase,
|
|
779
1056
|
knowledgeMaxTokens,
|
|
780
1057
|
enableUIControl,
|
|
781
1058
|
screenMap: useScreenMap ? screenMap : undefined,
|
|
@@ -783,13 +1060,65 @@ export function AIAgent({
|
|
|
783
1060
|
maxCostUSD,
|
|
784
1061
|
interactionMode,
|
|
785
1062
|
// Block the agent loop until user responds
|
|
786
|
-
onAskUser: mode === 'voice' ? undefined :
|
|
1063
|
+
onAskUser: mode === 'voice' ? undefined : request => {
|
|
787
1064
|
return new Promise(resolve => {
|
|
1065
|
+
const normalized = typeof request === 'string' ? {
|
|
1066
|
+
question: request,
|
|
1067
|
+
kind: 'freeform'
|
|
1068
|
+
} : request;
|
|
1069
|
+
const question = normalized.question;
|
|
1070
|
+
const kind = normalized.kind || 'freeform';
|
|
1071
|
+
logger.info('AIAgent', `❓ onAskUser invoked in ${mode} mode: "${question}"`);
|
|
1072
|
+
telemetryRef.current?.track('agent_trace', {
|
|
1073
|
+
stage: 'ask_user_prompt_rendered',
|
|
1074
|
+
question,
|
|
1075
|
+
mode,
|
|
1076
|
+
kind
|
|
1077
|
+
});
|
|
788
1078
|
askUserResolverRef.current = resolve;
|
|
789
|
-
|
|
1079
|
+
logger.info('AIAgent', `📌 askUserResolverRef SET (resolver stored) | kind=${kind} | pendingAppApprovalRef=${pendingAppApprovalRef.current}`);
|
|
1080
|
+
// If we're already waiting for a button tap, force approval kind regardless
|
|
1081
|
+
// of what the model passed — the user must tap a button to proceed.
|
|
1082
|
+
const forcedKind = pendingAppApprovalRef.current ? 'approval' : kind;
|
|
1083
|
+
pendingAskUserKindRef.current = forcedKind;
|
|
1084
|
+
if (forcedKind === 'approval') {
|
|
1085
|
+
pendingAppApprovalRef.current = true;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// If the user typed a message while we were thinking (queued answer),
|
|
1089
|
+
// resolve immediately with that message instead of blocking on a new prompt.
|
|
1090
|
+
const queued = queuedApprovalAnswerRef.current;
|
|
1091
|
+
if (queued !== null) {
|
|
1092
|
+
queuedApprovalAnswerRef.current = null;
|
|
1093
|
+
logger.info('AIAgent', `⚡ Auto-resolving ask_user with queued message: "${queued}"`);
|
|
1094
|
+
// Show the AI question in chat, clear the approval buttons (no resolver for them),
|
|
1095
|
+
// then immediately resolve the Promise with the queued message.
|
|
1096
|
+
setMessages(prev => [...prev, {
|
|
1097
|
+
id: `assistant-ask-${Date.now()}`,
|
|
1098
|
+
role: 'assistant',
|
|
1099
|
+
content: question,
|
|
1100
|
+
timestamp: Date.now(),
|
|
1101
|
+
promptKind: forcedKind === 'approval' ? 'approval' : undefined
|
|
1102
|
+
}]);
|
|
1103
|
+
askUserResolverRef.current = null;
|
|
1104
|
+
pendingAskUserKindRef.current = null;
|
|
1105
|
+
pendingAppApprovalRef.current = false; // CRITICAL FIX: Unlock the agent state
|
|
1106
|
+
setPendingApprovalQuestion(null); // clear any stale buttons — buttons with no resolver = dead tap
|
|
1107
|
+
resolve(queued);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
setPendingApprovalQuestion(forcedKind === 'approval' ? question : null);
|
|
1111
|
+
// Add AI question to the message thread so it appears in chat
|
|
1112
|
+
setMessages(prev => [...prev, {
|
|
1113
|
+
id: `assistant-ask-${Date.now()}`,
|
|
1114
|
+
role: 'assistant',
|
|
1115
|
+
content: question,
|
|
1116
|
+
timestamp: Date.now(),
|
|
1117
|
+
promptKind: kind === 'approval' ? 'approval' : undefined
|
|
1118
|
+
}]);
|
|
790
1119
|
setLastResult({
|
|
791
1120
|
success: true,
|
|
792
|
-
message:
|
|
1121
|
+
message: question,
|
|
793
1122
|
steps: []
|
|
794
1123
|
});
|
|
795
1124
|
setIsThinking(false);
|
|
@@ -800,8 +1129,20 @@ export function AIAgent({
|
|
|
800
1129
|
// so that AI-driven taps are never tracked as user_interaction events.
|
|
801
1130
|
onToolExecute: active => {
|
|
802
1131
|
telemetryRef.current?.setAgentActing(active);
|
|
1132
|
+
},
|
|
1133
|
+
onTrace: event => {
|
|
1134
|
+
telemetryRef.current?.track('agent_trace', {
|
|
1135
|
+
traceId: event.traceId,
|
|
1136
|
+
stage: event.stage,
|
|
1137
|
+
stepIndex: event.stepIndex,
|
|
1138
|
+
screenName: event.screenName,
|
|
1139
|
+
...(event.data ?? {})
|
|
1140
|
+
});
|
|
803
1141
|
}
|
|
804
|
-
}), [mode, apiKey, proxyUrl, proxyHeaders, voiceProxyUrl, voiceProxyHeaders, model, maxSteps, interactiveBlacklist, interactiveWhitelist, onBeforeStep, onAfterStep, onBeforeTask, onAfterTask, transformScreenContent, customTools, instructions, stepDelay, mcpServerUrl, router, pathname, onTokenUsage,
|
|
1142
|
+
}), [mode, apiKey, proxyUrl, proxyHeaders, voiceProxyUrl, voiceProxyHeaders, model, maxSteps, interactiveBlacklist, interactiveWhitelist, onBeforeStep, onAfterStep, onBeforeTask, onAfterTask, transformScreenContent, customTools, instructions, stepDelay, mcpServerUrl, router, pathname, onTokenUsage, resolvedKnowledgeBase, knowledgeMaxTokens, enableUIControl, screenMap, useScreenMap, maxTokenBudget, maxCostUSD, interactionMode]);
|
|
1143
|
+
useEffect(() => {
|
|
1144
|
+
logger.info('AIAgent', `⚙️ Runtime config recomputed: mode=${mode} interactionMode=${interactionMode || 'copilot(default)'} onAskUser=${mode !== 'voice'} mergedTools=${Object.keys(mergedCustomTools).join(', ') || '(none)'}`);
|
|
1145
|
+
}, [mode, interactionMode, mergedCustomTools]);
|
|
805
1146
|
const provider = useMemo(() => createProvider(providerName, apiKey, model, proxyUrl, proxyHeaders), [providerName, apiKey, model, proxyUrl, proxyHeaders]);
|
|
806
1147
|
const runtime = useMemo(() => new AgentRuntime(provider, config, rootViewRef.current, navRef),
|
|
807
1148
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -827,15 +1168,59 @@ export function AIAgent({
|
|
|
827
1168
|
analyticsKey,
|
|
828
1169
|
analyticsProxyUrl,
|
|
829
1170
|
analyticsProxyHeaders,
|
|
830
|
-
debug
|
|
1171
|
+
debug,
|
|
1172
|
+
onEvent: event => {
|
|
1173
|
+
// Proactive behavior triggers
|
|
1174
|
+
if (event.type === 'rage_tap' || event.type === 'error_screen' || event.type === 'repeated_navigation') {
|
|
1175
|
+
idleDetectorRef.current?.triggerBehavior(event.type, event.screen);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Customer Success features
|
|
1179
|
+
if (customerSuccess?.enabled && event.type === 'user_interaction' && event.data) {
|
|
1180
|
+
const action = String(event.data.label || event.data.action || '');
|
|
1181
|
+
|
|
1182
|
+
// Check milestones
|
|
1183
|
+
customerSuccess.successMilestones?.forEach(m => {
|
|
1184
|
+
if (m.action && m.action === action) {
|
|
1185
|
+
telemetry.track('health_signal', {
|
|
1186
|
+
type: 'success_milestone',
|
|
1187
|
+
milestone: m.name
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
// Check key feature adoption
|
|
1193
|
+
customerSuccess.keyFeatures?.forEach(feature => {
|
|
1194
|
+
if (action.includes(feature) || action === feature) {
|
|
1195
|
+
telemetry.track('health_signal', {
|
|
1196
|
+
type: 'feature_adoption',
|
|
1197
|
+
feature
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
831
1203
|
});
|
|
832
1204
|
telemetryRef.current = telemetry;
|
|
833
1205
|
bindTelemetryService(telemetry);
|
|
834
1206
|
telemetry.start();
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1207
|
+
// NavigationContainer is a child of AIAgent, so navRef may not be
|
|
1208
|
+
// ready yet when this effect runs. Poll briefly until it's available.
|
|
1209
|
+
const resolveInitialScreen = () => {
|
|
1210
|
+
let attempts = 0;
|
|
1211
|
+
const maxAttempts = 15; // 15 × 200ms = 3s max wait
|
|
1212
|
+
const timer = setInterval(() => {
|
|
1213
|
+
attempts++;
|
|
1214
|
+
const route = navRef?.getCurrentRoute?.();
|
|
1215
|
+
if (route?.name) {
|
|
1216
|
+
telemetry.setScreen(route.name);
|
|
1217
|
+
clearInterval(timer);
|
|
1218
|
+
} else if (attempts >= maxAttempts) {
|
|
1219
|
+
clearInterval(timer);
|
|
1220
|
+
}
|
|
1221
|
+
}, 200);
|
|
1222
|
+
};
|
|
1223
|
+
resolveInitialScreen();
|
|
839
1224
|
}); // initDeviceId
|
|
840
1225
|
}, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, bindTelemetryService, debug, navRef]);
|
|
841
1226
|
|
|
@@ -851,14 +1236,48 @@ export function AIAgent({
|
|
|
851
1236
|
// Track screen changes via navRef
|
|
852
1237
|
useEffect(() => {
|
|
853
1238
|
if (!navRef?.addListener || !telemetryRef.current) return;
|
|
1239
|
+
const checkScreenMilestone = screenName => {
|
|
1240
|
+
telemetryRef.current?.setScreen(screenName);
|
|
1241
|
+
if (customerSuccess?.enabled) {
|
|
1242
|
+
customerSuccess.successMilestones?.forEach(m => {
|
|
1243
|
+
if (m.screen && m.screen === screenName) {
|
|
1244
|
+
telemetryRef.current?.track('health_signal', {
|
|
1245
|
+
type: 'success_milestone',
|
|
1246
|
+
milestone: m.name
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if (isOnboardingActive && onboarding?.steps) {
|
|
1252
|
+
const step = onboarding.steps[currentOnboardingIndex];
|
|
1253
|
+
if (step && step.screen === screenName) {
|
|
1254
|
+
telemetryRef.current?.track('onboarding_step', {
|
|
1255
|
+
step_index: currentOnboardingIndex,
|
|
1256
|
+
screen: screenName,
|
|
1257
|
+
action: step.action || 'view'
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
// Pop the onboarding badge instantly
|
|
1261
|
+
setTimeout(() => {
|
|
1262
|
+
setProactiveBadgeText(step.message);
|
|
1263
|
+
setProactiveStage('badge');
|
|
1264
|
+
// Stop typical idle timers so it stays until dismissed or advanced
|
|
1265
|
+
idleDetectorRef.current?.dismiss();
|
|
1266
|
+
|
|
1267
|
+
// Auto advance logic
|
|
1268
|
+
advanceOnboarding();
|
|
1269
|
+
}, 300);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
854
1273
|
const unsubscribe = navRef.addListener('state', () => {
|
|
855
1274
|
const currentRoute = navRef.getCurrentRoute?.();
|
|
856
1275
|
if (currentRoute?.name) {
|
|
857
|
-
|
|
1276
|
+
checkScreenMilestone(currentRoute.name);
|
|
858
1277
|
}
|
|
859
1278
|
});
|
|
860
1279
|
return () => unsubscribe?.();
|
|
861
|
-
}, [navRef]);
|
|
1280
|
+
}, [navRef, customerSuccess, isOnboardingActive, onboarding, currentOnboardingIndex, advanceOnboarding]);
|
|
862
1281
|
|
|
863
1282
|
// ─── MCP Bridge ──────────────────────────────────────────────
|
|
864
1283
|
|
|
@@ -895,7 +1314,8 @@ export function AIAgent({
|
|
|
895
1314
|
setProactiveStage('badge');
|
|
896
1315
|
},
|
|
897
1316
|
onReset: () => setProactiveStage('hidden'),
|
|
898
|
-
generateSuggestion: () => proactiveHelp?.generateSuggestion?.(telemetryRef.current?.screen || 'Home') || proactiveHelp?.badgeText || "Need help with this screen?"
|
|
1317
|
+
generateSuggestion: () => proactiveHelp?.generateSuggestion?.(telemetryRef.current?.screen || 'Home') || proactiveHelp?.badgeText || "Need help with this screen?",
|
|
1318
|
+
behaviorTriggers: proactiveHelp?.behaviorTriggers
|
|
899
1319
|
});
|
|
900
1320
|
return () => {
|
|
901
1321
|
idleDetectorRef.current?.destroy();
|
|
@@ -940,7 +1360,7 @@ export function AIAgent({
|
|
|
940
1360
|
if (!audioOutputRef.current) {
|
|
941
1361
|
logger.info('AIAgent', 'Creating AudioOutputService...');
|
|
942
1362
|
audioOutputRef.current = new AudioOutputService({
|
|
943
|
-
onError: err => logger.
|
|
1363
|
+
onError: err => logger.warn('AIAgent', `AudioOutput error/disabled: ${err}`)
|
|
944
1364
|
});
|
|
945
1365
|
// IMPORTANT: Must await initialize() BEFORE starting mic.
|
|
946
1366
|
// initialize() calls setAudioSessionOptions which reconfigures the
|
|
@@ -960,7 +1380,7 @@ export function AIAgent({
|
|
|
960
1380
|
logger.info('AIAgent', `🎤 onAudioChunk: ${chunk.length} chars, voiceService=${!!voiceServiceRef.current}, connected=${voiceServiceRef.current?.isConnected}`);
|
|
961
1381
|
voiceServiceRef.current?.sendAudio(chunk);
|
|
962
1382
|
},
|
|
963
|
-
onError: err => logger.
|
|
1383
|
+
onError: err => logger.warn('AIAgent', `AudioInput error/disabled: ${err}`),
|
|
964
1384
|
onPermissionDenied: () => logger.warn('AIAgent', 'Mic permission denied by user')
|
|
965
1385
|
});
|
|
966
1386
|
}
|
|
@@ -1060,6 +1480,11 @@ export function AIAgent({
|
|
|
1060
1480
|
}
|
|
1061
1481
|
toolLockRef.current = true;
|
|
1062
1482
|
try {
|
|
1483
|
+
// Trigger visual 'thinking' overlay down to ChatBar so user knows action is happening
|
|
1484
|
+
setIsThinking(true);
|
|
1485
|
+
const toolNameFriendly = toolCall.name.replace(/_/g, ' ');
|
|
1486
|
+
setStatusText(`Executing ${toolNameFriendly}...`);
|
|
1487
|
+
|
|
1063
1488
|
// Execute the tool via AgentRuntime and send result back to Gemini
|
|
1064
1489
|
const result = await runtime.executeTool(toolCall.name, toolCall.args);
|
|
1065
1490
|
logger.info('AIAgent', `🔧 Tool result for ${toolCall.name}: ${result}`);
|
|
@@ -1079,6 +1504,9 @@ export function AIAgent({
|
|
|
1079
1504
|
logger.info('AIAgent', `📡 Tool response sent for ${toolCall.name} [id=${toolCall.id}]`);
|
|
1080
1505
|
} finally {
|
|
1081
1506
|
toolLockRef.current = false;
|
|
1507
|
+
setIsThinking(false);
|
|
1508
|
+
setStatusText('');
|
|
1509
|
+
|
|
1082
1510
|
// Resume mic after tool response is sent
|
|
1083
1511
|
if (voiceServiceRef.current?.isConnected) {
|
|
1084
1512
|
audioInputRef.current?.start().then(ok => {
|
|
@@ -1185,6 +1613,21 @@ export function AIAgent({
|
|
|
1185
1613
|
|
|
1186
1614
|
const handleSend = useCallback(async (message, options) => {
|
|
1187
1615
|
if (!message.trim() || isThinking) return;
|
|
1616
|
+
|
|
1617
|
+
// ── Apple Guideline 5.1.2(i): Consent gate ──────────────────
|
|
1618
|
+
// If consent is required but not yet granted, show the consent dialog
|
|
1619
|
+
// instead of sending data to the AI provider.
|
|
1620
|
+
// EXCEPTION: bypass consent when talking to a human agent —
|
|
1621
|
+
// this is a person-to-person chat, not AI data processing.
|
|
1622
|
+
const isHumanAgentChat = !!(selectedTicketId && supportSocket);
|
|
1623
|
+
if (consentGateActive && !isHumanAgentChat) {
|
|
1624
|
+
pendingConsentSendRef.current = {
|
|
1625
|
+
message: message.trim(),
|
|
1626
|
+
options
|
|
1627
|
+
};
|
|
1628
|
+
setShowConsentDialog(true);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1188
1631
|
logger.info('AIAgent', `User message: "${message}"`);
|
|
1189
1632
|
setLastUserMessage(message.trim());
|
|
1190
1633
|
|
|
@@ -1203,7 +1646,7 @@ export function AIAgent({
|
|
|
1203
1646
|
return;
|
|
1204
1647
|
}
|
|
1205
1648
|
if (supportSocket.sendText(message)) {
|
|
1206
|
-
|
|
1649
|
+
setSupportMessages(prev => [...prev, {
|
|
1207
1650
|
id: `user-${Date.now()}`,
|
|
1208
1651
|
role: 'user',
|
|
1209
1652
|
content: message.trim(),
|
|
@@ -1235,74 +1678,302 @@ export function AIAgent({
|
|
|
1235
1678
|
|
|
1236
1679
|
// If there's a pending ask_user, resolve it instead of starting a new execution
|
|
1237
1680
|
if (askUserResolverRef.current) {
|
|
1681
|
+
if (pendingAskUserKindRef.current === 'approval') {
|
|
1682
|
+
const resolver = askUserResolverRef.current;
|
|
1683
|
+
askUserResolverRef.current = null;
|
|
1684
|
+
pendingAskUserKindRef.current = null;
|
|
1685
|
+
pendingAppApprovalRef.current = false; // CRITICAL FIX: Unlock the agent state
|
|
1686
|
+
setPendingApprovalQuestion(null);
|
|
1687
|
+
|
|
1688
|
+
// Pass the user's conversational message directly back to the active prompt resolver.
|
|
1689
|
+
// It will NOT be treated as a rejection! It will be passed back to the LLM.
|
|
1690
|
+
telemetryRef.current?.track('agent_trace', {
|
|
1691
|
+
stage: 'approval_interrupted_by_user_question',
|
|
1692
|
+
message: message.trim()
|
|
1693
|
+
});
|
|
1694
|
+
setIsThinking(true);
|
|
1695
|
+
setStatusText('Answering your question...');
|
|
1696
|
+
setLastResult(null);
|
|
1697
|
+
resolver(message.trim());
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1238
1700
|
const resolver = askUserResolverRef.current;
|
|
1239
1701
|
askUserResolverRef.current = null;
|
|
1702
|
+
pendingAskUserKindRef.current = null;
|
|
1703
|
+
pendingAppApprovalRef.current = false; // CRITICAL FIX: Unlock the agent state
|
|
1704
|
+
setPendingApprovalQuestion(null);
|
|
1240
1705
|
setIsThinking(true);
|
|
1241
1706
|
setStatusText('Processing your answer...');
|
|
1242
1707
|
setLastResult(null);
|
|
1708
|
+
logger.info('AIAgent', `✅ Resolving pending ask_user with: "${message.trim()}"`);
|
|
1709
|
+
telemetryRef.current?.track('agent_trace', {
|
|
1710
|
+
stage: 'ask_user_answer_submitted',
|
|
1711
|
+
answer: message.trim(),
|
|
1712
|
+
mode
|
|
1713
|
+
});
|
|
1243
1714
|
resolver(message);
|
|
1244
1715
|
return;
|
|
1245
1716
|
}
|
|
1246
1717
|
|
|
1718
|
+
// Guard: if we're mid-approval flow (waiting for button tap) but no resolver exists yet
|
|
1719
|
+
// (agent is still thinking between ask_user calls), do NOT start a new task —
|
|
1720
|
+
// that would spawn two concurrent agent loops and freeze the app.
|
|
1721
|
+
// Instead, store the message as a queued answer that will auto-resolve on the next ask_user.
|
|
1722
|
+
if (pendingAppApprovalRef.current) {
|
|
1723
|
+
logger.warn('AIAgent', '⚠️ User typed during active approval flow — queuing message, not spawning new task');
|
|
1724
|
+
queuedApprovalAnswerRef.current = message.trim();
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1247
1728
|
// Normal execution — new task
|
|
1729
|
+
// Reset approval gate refs so previous conversations don't bleed state
|
|
1730
|
+
pendingAppApprovalRef.current = false;
|
|
1731
|
+
queuedApprovalAnswerRef.current = null;
|
|
1248
1732
|
setIsThinking(true);
|
|
1249
1733
|
setStatusText('Thinking...');
|
|
1250
1734
|
setLastResult(null);
|
|
1735
|
+
logger.info('AIAgent', `📨 New user request received in ${mode} mode | interactionMode=${interactionMode || 'copilot(default)'} | text="${message.trim()}"`);
|
|
1251
1736
|
|
|
1252
1737
|
// Telemetry: track agent request
|
|
1253
1738
|
telemetryRef.current?.track('agent_request', {
|
|
1254
|
-
query: message.trim()
|
|
1739
|
+
query: message.trim(),
|
|
1740
|
+
transcript: message.trim(),
|
|
1741
|
+
mode
|
|
1742
|
+
});
|
|
1743
|
+
telemetryRef.current?.track('agent_trace', {
|
|
1744
|
+
stage: 'request_received',
|
|
1745
|
+
query: message.trim(),
|
|
1746
|
+
mode,
|
|
1747
|
+
interactionMode: interactionMode || 'copilot'
|
|
1255
1748
|
});
|
|
1256
1749
|
try {
|
|
1750
|
+
// ─── Business-grade escalation policy ───
|
|
1751
|
+
const FRUSTRATION_REGEX = /\b(angry|frustrated|useless|terrible|hate|worst|ridiculous|awful)\b/i;
|
|
1752
|
+
const HIGH_RISK_ESCALATION_REGEX = /\b(human|agent|representative|supervisor|manager|refund|chargeback|charged|billing|payment|fraud|scam|lawsuit|attorney|lawyer|sue|legal|privacy|data breach|account locked|can't log in|cannot log in)\b/i;
|
|
1753
|
+
const escalateTool = customTools?.['escalate_to_human'] || autoEscalateTool;
|
|
1754
|
+
const priorFrustrationCount = messages.filter(m => m.role === 'user' && typeof m.content === 'string' && FRUSTRATION_REGEX.test(m.content)).length;
|
|
1755
|
+
if (escalateTool && !selectedTicketId) {
|
|
1756
|
+
if (HIGH_RISK_ESCALATION_REGEX.test(message)) {
|
|
1757
|
+
logger.warn('AIAgent', 'High-risk support signal detected — auto-escalating to human');
|
|
1758
|
+
telemetryRef.current?.track('business_escalation', {
|
|
1759
|
+
message,
|
|
1760
|
+
trigger: 'high_risk'
|
|
1761
|
+
});
|
|
1762
|
+
const escalationResult = await escalateTool.execute({
|
|
1763
|
+
reason: `Customer needs human support: ${message.trim()}`
|
|
1764
|
+
});
|
|
1765
|
+
const customerMessage = typeof escalationResult === 'string' && escalationResult.trim().length > 0 ? escalationResult.replace(/^ESCALATED:\s*/i, '') : 'Your request has been sent to our support team. A human agent will reply here as soon as possible.';
|
|
1766
|
+
const res = {
|
|
1767
|
+
success: true,
|
|
1768
|
+
message: customerMessage,
|
|
1769
|
+
steps: [{
|
|
1770
|
+
stepIndex: 0,
|
|
1771
|
+
reflection: {
|
|
1772
|
+
previousGoalEval: '',
|
|
1773
|
+
memory: '',
|
|
1774
|
+
plan: 'Escalate to human support for a high-risk or explicitly requested issue'
|
|
1775
|
+
},
|
|
1776
|
+
action: {
|
|
1777
|
+
name: 'escalate_to_human',
|
|
1778
|
+
input: {
|
|
1779
|
+
reason: 'business_escalation'
|
|
1780
|
+
},
|
|
1781
|
+
output: 'Escalated'
|
|
1782
|
+
}
|
|
1783
|
+
}]
|
|
1784
|
+
};
|
|
1785
|
+
setLastResult(res);
|
|
1786
|
+
setMessages(prev => [...prev, {
|
|
1787
|
+
id: `assistant-${Date.now()}`,
|
|
1788
|
+
role: 'assistant',
|
|
1789
|
+
content: res.message,
|
|
1790
|
+
timestamp: Date.now(),
|
|
1791
|
+
result: res
|
|
1792
|
+
}]);
|
|
1793
|
+
setIsThinking(false);
|
|
1794
|
+
setStatusText('');
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (FRUSTRATION_REGEX.test(message)) {
|
|
1798
|
+
const frustrationMessage = priorFrustrationCount > 0 ? "I'm sorry this has been frustrating. I can keep helping here, or I can connect you with a human support agent if you'd prefer." : "I'm sorry this has been frustrating. Tell me what went wrong, and I'll do my best to fix it.";
|
|
1799
|
+
const res = {
|
|
1800
|
+
success: true,
|
|
1801
|
+
message: frustrationMessage,
|
|
1802
|
+
steps: [{
|
|
1803
|
+
stepIndex: 0,
|
|
1804
|
+
reflection: {
|
|
1805
|
+
previousGoalEval: '',
|
|
1806
|
+
memory: '',
|
|
1807
|
+
plan: priorFrustrationCount > 0 ? 'Acknowledge repeated frustration and offer escalation without forcing a handoff' : 'Acknowledge first-time frustration and continue trying to resolve the issue'
|
|
1808
|
+
},
|
|
1809
|
+
action: {
|
|
1810
|
+
name: 'done',
|
|
1811
|
+
input: {},
|
|
1812
|
+
output: frustrationMessage
|
|
1813
|
+
}
|
|
1814
|
+
}]
|
|
1815
|
+
};
|
|
1816
|
+
setLastResult(res);
|
|
1817
|
+
setMessages(prev => [...prev, {
|
|
1818
|
+
id: `assistant-${Date.now()}`,
|
|
1819
|
+
role: 'assistant',
|
|
1820
|
+
content: frustrationMessage,
|
|
1821
|
+
timestamp: Date.now(),
|
|
1822
|
+
result: res
|
|
1823
|
+
}]);
|
|
1824
|
+
setIsThinking(false);
|
|
1825
|
+
setStatusText('');
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1257
1830
|
// Ensure we have the latest Fiber tree ref
|
|
1258
1831
|
runtime.updateRefs(rootViewRef.current, navRef);
|
|
1259
1832
|
const result = await runtime.execute(message, messages);
|
|
1833
|
+
let normalizedResult = result;
|
|
1834
|
+
const reportStep = result.steps?.find(step => step.action.name === 'report_issue');
|
|
1835
|
+
if (reportStep && typeof reportStep.action.output === 'string') {
|
|
1836
|
+
const match = /^ISSUE_REPORTED:([^:]+):([^:]*):([\s\S]+)$/i.exec(reportStep.action.output);
|
|
1837
|
+
if (match) {
|
|
1838
|
+
const [, _issueId, historyId, customerMessage] = match;
|
|
1839
|
+
const resolvedCustomerMessage = customerMessage || reportStep.action.output;
|
|
1840
|
+
if (historyId) {
|
|
1841
|
+
seenReportedIssueUpdatesRef.current.add(historyId);
|
|
1842
|
+
}
|
|
1843
|
+
normalizedResult = {
|
|
1844
|
+
...result,
|
|
1845
|
+
message: resolvedCustomerMessage,
|
|
1846
|
+
steps: result.steps.map(step => step === reportStep ? {
|
|
1847
|
+
...step,
|
|
1848
|
+
action: {
|
|
1849
|
+
...step.action,
|
|
1850
|
+
output: resolvedCustomerMessage
|
|
1851
|
+
}
|
|
1852
|
+
} : step)
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1260
1856
|
|
|
1261
1857
|
// Telemetry: track agent completion and per-step details
|
|
1262
1858
|
if (telemetryRef.current) {
|
|
1263
|
-
|
|
1859
|
+
if (!agentFrtFiredRef.current) {
|
|
1860
|
+
agentFrtFiredRef.current = true;
|
|
1861
|
+
telemetryRef.current.track('agent_first_response');
|
|
1862
|
+
}
|
|
1863
|
+
for (const step of normalizedResult.steps ?? []) {
|
|
1264
1864
|
telemetryRef.current.track('agent_step', {
|
|
1865
|
+
stepIndex: step.stepIndex,
|
|
1265
1866
|
tool: step.action.name,
|
|
1266
1867
|
args: step.action.input,
|
|
1267
|
-
result: typeof step.action.output === 'string' ? step.action.output
|
|
1868
|
+
result: typeof step.action.output === 'string' ? step.action.output : String(step.action.output),
|
|
1869
|
+
plan: step.reflection.plan,
|
|
1870
|
+
memory: step.reflection.memory,
|
|
1871
|
+
previousGoalEval: step.reflection.previousGoalEval
|
|
1268
1872
|
});
|
|
1269
1873
|
}
|
|
1270
1874
|
telemetryRef.current.track('agent_complete', {
|
|
1271
|
-
success:
|
|
1272
|
-
steps:
|
|
1273
|
-
tokens:
|
|
1274
|
-
cost:
|
|
1875
|
+
success: normalizedResult.success,
|
|
1876
|
+
steps: normalizedResult.steps?.length ?? 0,
|
|
1877
|
+
tokens: normalizedResult.tokenUsage?.totalTokens ?? 0,
|
|
1878
|
+
cost: normalizedResult.tokenUsage?.estimatedCostUSD ?? 0,
|
|
1879
|
+
response: normalizedResult.message,
|
|
1880
|
+
conversation: {
|
|
1881
|
+
user: message.trim(),
|
|
1882
|
+
assistant: normalizedResult.message
|
|
1883
|
+
}
|
|
1275
1884
|
});
|
|
1276
1885
|
}
|
|
1277
|
-
logger.info('AIAgent', '★ handleSend — SETTING lastResult:',
|
|
1886
|
+
logger.info('AIAgent', '★ handleSend — SETTING lastResult:', normalizedResult.message.substring(0, 80), '| mode:', mode);
|
|
1278
1887
|
logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
1279
1888
|
|
|
1280
1889
|
// Don't overwrite lastResult if escalation already switched us to human mode
|
|
1281
1890
|
// (mode in this closure is stale — the actual mode may have changed during async execution)
|
|
1282
|
-
const stepsHadEscalation =
|
|
1891
|
+
const stepsHadEscalation = normalizedResult.steps?.some(s => s.action.name === 'escalate_to_human');
|
|
1283
1892
|
if (!stepsHadEscalation) {
|
|
1284
|
-
setLastResult(
|
|
1893
|
+
setLastResult(normalizedResult);
|
|
1285
1894
|
}
|
|
1286
|
-
|
|
1895
|
+
const assistantMsg = {
|
|
1287
1896
|
id: Date.now().toString() + Math.random(),
|
|
1288
1897
|
role: 'assistant',
|
|
1289
|
-
content:
|
|
1898
|
+
content: normalizedResult.message,
|
|
1290
1899
|
timestamp: Date.now(),
|
|
1291
|
-
result
|
|
1292
|
-
}
|
|
1900
|
+
result: normalizedResult
|
|
1901
|
+
};
|
|
1902
|
+
setMessages(prev => [...prev, assistantMsg]);
|
|
1903
|
+
|
|
1904
|
+
// ── Persist to backend (debounced 600ms) ─────────────────────
|
|
1905
|
+
if (analyticsKey) {
|
|
1906
|
+
if (appendDebounceRef.current) clearTimeout(appendDebounceRef.current);
|
|
1907
|
+
appendDebounceRef.current = setTimeout(async () => {
|
|
1908
|
+
try {
|
|
1909
|
+
await initDeviceId();
|
|
1910
|
+
const deviceId = getDeviceId();
|
|
1911
|
+
// Read current messages directly from ref — never use setMessages() to read state
|
|
1912
|
+
const currentMsgs = messagesRef.current;
|
|
1913
|
+
const newMsgs = currentMsgs.slice(lastSavedMessageCountRef.current);
|
|
1914
|
+
if (newMsgs.length === 0) return;
|
|
1915
|
+
if (!activeConversationIdRef.current) {
|
|
1916
|
+
// First exchange — create a new conversation
|
|
1917
|
+
const id = await ConversationService.startConversation({
|
|
1918
|
+
analyticsKey: analyticsKey,
|
|
1919
|
+
userId: userContext?.userId,
|
|
1920
|
+
deviceId: deviceId || undefined,
|
|
1921
|
+
messages: newMsgs
|
|
1922
|
+
});
|
|
1923
|
+
if (id) {
|
|
1924
|
+
activeConversationIdRef.current = id;
|
|
1925
|
+
lastSavedMessageCountRef.current = currentMsgs.length;
|
|
1926
|
+
const newSummary = {
|
|
1927
|
+
id,
|
|
1928
|
+
title: newMsgs.find(m => m.role === 'user')?.content?.slice(0, 80) || 'New conversation',
|
|
1929
|
+
preview: assistantMsg.content.slice(0, 100),
|
|
1930
|
+
previewRole: 'assistant',
|
|
1931
|
+
messageCount: newMsgs.length,
|
|
1932
|
+
createdAt: Date.now(),
|
|
1933
|
+
updatedAt: Date.now()
|
|
1934
|
+
};
|
|
1935
|
+
setConversations(prev => [newSummary, ...prev]);
|
|
1936
|
+
logger.info('AIAgent', `Conversation created: ${id}`);
|
|
1937
|
+
}
|
|
1938
|
+
} else {
|
|
1939
|
+
// Subsequent turns — append only new messages
|
|
1940
|
+
await ConversationService.appendMessages({
|
|
1941
|
+
conversationId: activeConversationIdRef.current,
|
|
1942
|
+
analyticsKey: analyticsKey,
|
|
1943
|
+
messages: newMsgs
|
|
1944
|
+
});
|
|
1945
|
+
lastSavedMessageCountRef.current = currentMsgs.length;
|
|
1946
|
+
setConversations(prev => prev.map(c => c.id === activeConversationIdRef.current ? {
|
|
1947
|
+
...c,
|
|
1948
|
+
preview: assistantMsg.content.slice(0, 100),
|
|
1949
|
+
updatedAt: Date.now(),
|
|
1950
|
+
messageCount: c.messageCount + newMsgs.length
|
|
1951
|
+
} : c));
|
|
1952
|
+
logger.info('AIAgent', `Conversation appended: ${activeConversationIdRef.current}`);
|
|
1953
|
+
}
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
logger.warn('AIAgent', 'Failed to persist conversation:', err);
|
|
1956
|
+
}
|
|
1957
|
+
}, 600);
|
|
1958
|
+
}
|
|
1293
1959
|
if (options?.onResult) {
|
|
1294
|
-
options.onResult(
|
|
1960
|
+
options.onResult(normalizedResult);
|
|
1295
1961
|
} else {
|
|
1296
|
-
onResult?.(
|
|
1962
|
+
onResult?.(normalizedResult);
|
|
1297
1963
|
}
|
|
1298
|
-
logger.info('AIAgent', `Result: ${
|
|
1964
|
+
logger.info('AIAgent', `Result: ${normalizedResult.success ? '✅' : '❌'} ${normalizedResult.message}`);
|
|
1299
1965
|
} catch (error) {
|
|
1300
1966
|
logger.error('AIAgent', 'Execution failed:', error);
|
|
1301
1967
|
|
|
1302
1968
|
// Telemetry: track agent failure
|
|
1303
1969
|
telemetryRef.current?.track('agent_complete', {
|
|
1304
1970
|
success: false,
|
|
1305
|
-
error: error.message
|
|
1971
|
+
error: error.message,
|
|
1972
|
+
response: `Error: ${error.message}`,
|
|
1973
|
+
conversation: {
|
|
1974
|
+
user: message.trim(),
|
|
1975
|
+
assistant: `Error: ${error.message}`
|
|
1976
|
+
}
|
|
1306
1977
|
});
|
|
1307
1978
|
setLastResult({
|
|
1308
1979
|
success: false,
|
|
@@ -1313,7 +1984,21 @@ export function AIAgent({
|
|
|
1313
1984
|
setIsThinking(false);
|
|
1314
1985
|
setStatusText('');
|
|
1315
1986
|
}
|
|
1316
|
-
}, [runtime, navRef, onResult, messages, isThinking]);
|
|
1987
|
+
}, [runtime, navRef, onResult, messages, isThinking, consentGateActive]);
|
|
1988
|
+
useEffect(() => {
|
|
1989
|
+
if (consentGateActive) return;
|
|
1990
|
+
const pending = pendingConsentSendRef.current;
|
|
1991
|
+
if (!pending) return;
|
|
1992
|
+
pendingConsentSendRef.current = null;
|
|
1993
|
+
void handleSend(pending.message, pending.options);
|
|
1994
|
+
}, [consentGateActive, handleSend]);
|
|
1995
|
+
useEffect(() => {
|
|
1996
|
+
if (isThinking) return;
|
|
1997
|
+
const pending = pendingFollowUpAfterApprovalRef.current;
|
|
1998
|
+
if (!pending) return;
|
|
1999
|
+
pendingFollowUpAfterApprovalRef.current = null;
|
|
2000
|
+
void handleSend(pending.message, pending.options);
|
|
2001
|
+
}, [isThinking, handleSend]);
|
|
1317
2002
|
|
|
1318
2003
|
// ─── Context value (for useAI bridge) ─────────────────────────
|
|
1319
2004
|
|
|
@@ -1322,6 +2007,33 @@ export function AIAgent({
|
|
|
1322
2007
|
setIsThinking(false);
|
|
1323
2008
|
setStatusText('');
|
|
1324
2009
|
}, [runtime]);
|
|
2010
|
+
|
|
2011
|
+
// ─── Conversation History Handlers ─────────────────────────────
|
|
2012
|
+
|
|
2013
|
+
const handleConversationSelect = useCallback(async conversationId => {
|
|
2014
|
+
if (!analyticsKey) return;
|
|
2015
|
+
try {
|
|
2016
|
+
const msgs = await ConversationService.fetchConversation({
|
|
2017
|
+
conversationId,
|
|
2018
|
+
analyticsKey
|
|
2019
|
+
});
|
|
2020
|
+
if (msgs) {
|
|
2021
|
+
activeConversationIdRef.current = conversationId;
|
|
2022
|
+
lastSavedMessageCountRef.current = msgs.length;
|
|
2023
|
+
setMessages(msgs);
|
|
2024
|
+
setLastResult(null);
|
|
2025
|
+
}
|
|
2026
|
+
} catch (err) {
|
|
2027
|
+
logger.warn('AIAgent', 'Failed to load conversation:', err);
|
|
2028
|
+
}
|
|
2029
|
+
}, [analyticsKey]);
|
|
2030
|
+
const handleNewConversation = useCallback(() => {
|
|
2031
|
+
activeConversationIdRef.current = null;
|
|
2032
|
+
lastSavedMessageCountRef.current = 0;
|
|
2033
|
+
setMessages([]);
|
|
2034
|
+
setLastResult(null);
|
|
2035
|
+
setLastUserMessage(null);
|
|
2036
|
+
}, []);
|
|
1325
2037
|
const contextValue = useMemo(() => ({
|
|
1326
2038
|
runtime,
|
|
1327
2039
|
send: handleSend,
|
|
@@ -1348,7 +2060,7 @@ export function AIAgent({
|
|
|
1348
2060
|
// Skip if the AI agent is currently executing a tool — those are
|
|
1349
2061
|
// already tracked as `agent_step` events with full context.
|
|
1350
2062
|
if (telemetryRef.current && !telemetryRef.current.isAgentActing) {
|
|
1351
|
-
const label = extractTouchLabel(event
|
|
2063
|
+
const label = extractTouchLabel(event);
|
|
1352
2064
|
if (label && label !== 'Unknown Element' && label !== '[pressable]') {
|
|
1353
2065
|
telemetryRef.current.track('user_interaction', {
|
|
1354
2066
|
type: 'tap',
|
|
@@ -1381,91 +2093,145 @@ export function AIAgent({
|
|
|
1381
2093
|
},
|
|
1382
2094
|
children: children
|
|
1383
2095
|
})
|
|
1384
|
-
}), /*#__PURE__*/
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
2096
|
+
}), /*#__PURE__*/_jsx(FloatingOverlayWrapper, {
|
|
2097
|
+
fallbackStyle: styles.floatingLayer,
|
|
2098
|
+
children: /*#__PURE__*/_jsxs(View, {
|
|
2099
|
+
style: StyleSheet.absoluteFill,
|
|
2100
|
+
pointerEvents: "box-none",
|
|
2101
|
+
children: [/*#__PURE__*/_jsx(HighlightOverlay, {}), showChatBar && /*#__PURE__*/_jsx(ProactiveHint, {
|
|
2102
|
+
stage: proactiveStage,
|
|
2103
|
+
badgeText: proactiveBadgeText,
|
|
2104
|
+
onDismiss: () => idleDetectorRef.current?.dismiss(),
|
|
2105
|
+
children: /*#__PURE__*/_jsx(AgentChatBar, {
|
|
2106
|
+
onSend: handleSend,
|
|
2107
|
+
isThinking: isThinking,
|
|
2108
|
+
statusText: overlayStatusText,
|
|
2109
|
+
lastResult: lastResult,
|
|
2110
|
+
lastUserMessage: lastUserMessage,
|
|
2111
|
+
chatMessages: messages,
|
|
2112
|
+
pendingApprovalQuestion: pendingApprovalQuestion,
|
|
2113
|
+
onPendingApprovalAction: action => {
|
|
2114
|
+
const resolver = askUserResolverRef.current;
|
|
2115
|
+
logger.info('AIAgent', `🔘 Approval button tapped: action=${action} | resolver=${resolver ? 'EXISTS' : 'NULL'} | pendingApprovalQuestion="${pendingApprovalQuestion}" | pendingAppApprovalRef=${pendingAppApprovalRef.current}`);
|
|
2116
|
+
if (!resolver) {
|
|
2117
|
+
logger.error('AIAgent', '🚫 ABORT: resolver is null when button was tapped — this means ask_user Promise was already resolved without clearing the buttons. This is a state sync bug.');
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
askUserResolverRef.current = null;
|
|
2121
|
+
pendingAskUserKindRef.current = null;
|
|
2122
|
+
// Button was actually tapped — clear the approval gate and any queued message
|
|
2123
|
+
pendingAppApprovalRef.current = false;
|
|
2124
|
+
queuedApprovalAnswerRef.current = null;
|
|
2125
|
+
setPendingApprovalQuestion(null);
|
|
2126
|
+
const response = action === 'approve' ? '__APPROVAL_GRANTED__' : '__APPROVAL_REJECTED__';
|
|
2127
|
+
// Restore the thinking overlay so the user can see the agent working
|
|
2128
|
+
// after approval. onAskUser set isThinking=false when buttons appeared,
|
|
2129
|
+
// but the agent is still running — restore the visual indicator.
|
|
2130
|
+
if (action === 'approve') {
|
|
2131
|
+
setIsThinking(true);
|
|
2132
|
+
setStatusText('Working...');
|
|
2133
|
+
}
|
|
2134
|
+
telemetryRef.current?.track('agent_trace', {
|
|
2135
|
+
stage: 'approval_button_pressed',
|
|
2136
|
+
action
|
|
2137
|
+
});
|
|
2138
|
+
resolver(response);
|
|
2139
|
+
},
|
|
2140
|
+
language: 'en',
|
|
2141
|
+
onDismiss: () => {
|
|
2142
|
+
setLastResult(null);
|
|
2143
|
+
setLastUserMessage(null);
|
|
2144
|
+
},
|
|
2145
|
+
theme: accentColor || theme ? {
|
|
2146
|
+
...(accentColor ? {
|
|
2147
|
+
primaryColor: accentColor
|
|
2148
|
+
} : {}),
|
|
2149
|
+
...theme
|
|
2150
|
+
} : undefined,
|
|
2151
|
+
availableModes: availableModes,
|
|
2152
|
+
mode: mode,
|
|
2153
|
+
onModeChange: newMode => {
|
|
2154
|
+
logger.info('AIAgent', '★ onModeChange:', mode, '→', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
2155
|
+
setMode(newMode);
|
|
2156
|
+
},
|
|
2157
|
+
isMicActive: isMicActive,
|
|
2158
|
+
isSpeakerMuted: isSpeakerMuted,
|
|
2159
|
+
isAISpeaking: isAISpeaking,
|
|
2160
|
+
isAgentTyping: isLiveAgentTyping,
|
|
2161
|
+
onStopSession: stopVoiceSession,
|
|
2162
|
+
isVoiceConnected: isVoiceConnected,
|
|
2163
|
+
onMicToggle: active => {
|
|
2164
|
+
if (active && !isVoiceConnected) {
|
|
2165
|
+
logger.warn('AIAgent', 'Cannot toggle mic — VoiceService not connected yet');
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
logger.info('AIAgent', `Mic toggle: ${active ? 'ON' : 'OFF'}`);
|
|
2169
|
+
setIsMicActive(active);
|
|
2170
|
+
if (active) {
|
|
2171
|
+
logger.info('AIAgent', 'Starting AudioInput...');
|
|
2172
|
+
audioInputRef.current?.start().then(ok => {
|
|
2173
|
+
logger.info('AIAgent', `AudioInput start result: ${ok}`);
|
|
2174
|
+
});
|
|
2175
|
+
} else {
|
|
2176
|
+
logger.info('AIAgent', 'Stopping AudioInput...');
|
|
2177
|
+
audioInputRef.current?.stop();
|
|
2178
|
+
}
|
|
2179
|
+
},
|
|
2180
|
+
onSpeakerToggle: muted => {
|
|
2181
|
+
logger.info('AIAgent', `Speaker toggle: ${muted ? 'MUTED' : 'UNMUTED'}`);
|
|
2182
|
+
setIsSpeakerMuted(muted);
|
|
2183
|
+
if (muted) {
|
|
2184
|
+
audioOutputRef.current?.mute();
|
|
2185
|
+
} else {
|
|
2186
|
+
audioOutputRef.current?.unmute();
|
|
2187
|
+
}
|
|
2188
|
+
},
|
|
2189
|
+
tickets: tickets,
|
|
2190
|
+
selectedTicketId: selectedTicketId,
|
|
2191
|
+
onTicketSelect: handleTicketSelect,
|
|
2192
|
+
onBackToTickets: handleBackToTickets,
|
|
2193
|
+
autoExpandTrigger: autoExpandTrigger,
|
|
2194
|
+
unreadCounts: unreadCounts,
|
|
2195
|
+
totalUnread: totalUnread,
|
|
2196
|
+
showDiscoveryTooltip: tooltipVisible,
|
|
2197
|
+
discoveryTooltipMessage: discoveryTooltipMessage,
|
|
2198
|
+
onTooltipDismiss: handleTooltipDismiss,
|
|
2199
|
+
conversations: conversations,
|
|
2200
|
+
isLoadingHistory: isLoadingHistory,
|
|
2201
|
+
onConversationSelect: handleConversationSelect,
|
|
2202
|
+
onNewConversation: handleNewConversation
|
|
2203
|
+
})
|
|
2204
|
+
}), /*#__PURE__*/_jsx(AgentOverlay, {
|
|
2205
|
+
visible: overlayVisible,
|
|
2206
|
+
statusText: overlayStatusText,
|
|
2207
|
+
onCancel: handleCancel
|
|
2208
|
+
}), /*#__PURE__*/_jsx(SupportChatModal, {
|
|
2209
|
+
visible: mode === 'human' && !!selectedTicketId,
|
|
2210
|
+
messages: supportMessages,
|
|
1396
2211
|
onSend: handleSend,
|
|
2212
|
+
onClose: handleBackToTickets,
|
|
2213
|
+
isAgentTyping: isLiveAgentTyping,
|
|
1397
2214
|
isThinking: isThinking,
|
|
1398
|
-
|
|
1399
|
-
|
|
2215
|
+
scrollToEndTrigger: chatScrollTrigger,
|
|
2216
|
+
ticketStatus: tickets.find(t => t.id === selectedTicketId)?.status
|
|
2217
|
+
}), /*#__PURE__*/_jsx(AIConsentDialog, {
|
|
2218
|
+
visible: showConsentDialog,
|
|
2219
|
+
provider: providerName,
|
|
2220
|
+
config: consentConfig,
|
|
1400
2221
|
language: 'en',
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
...(accentColor ? {
|
|
1407
|
-
primaryColor: accentColor
|
|
1408
|
-
} : {}),
|
|
1409
|
-
...theme
|
|
1410
|
-
} : undefined,
|
|
1411
|
-
availableModes: availableModes,
|
|
1412
|
-
mode: mode,
|
|
1413
|
-
onModeChange: newMode => {
|
|
1414
|
-
logger.info('AIAgent', '★ onModeChange:', mode, '→', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
|
|
1415
|
-
setMode(newMode);
|
|
2222
|
+
onConsent: async () => {
|
|
2223
|
+
await grantConsent();
|
|
2224
|
+
setShowConsentDialog(false);
|
|
2225
|
+
consentConfig.onConsent?.();
|
|
2226
|
+
logger.info('AIAgent', '✅ AI consent granted by user');
|
|
1416
2227
|
},
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
if (active && !isVoiceConnected) {
|
|
1425
|
-
logger.warn('AIAgent', 'Cannot toggle mic — VoiceService not connected yet');
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
logger.info('AIAgent', `Mic toggle: ${active ? 'ON' : 'OFF'}`);
|
|
1429
|
-
setIsMicActive(active);
|
|
1430
|
-
if (active) {
|
|
1431
|
-
logger.info('AIAgent', 'Starting AudioInput...');
|
|
1432
|
-
audioInputRef.current?.start().then(ok => {
|
|
1433
|
-
logger.info('AIAgent', `AudioInput start result: ${ok}`);
|
|
1434
|
-
});
|
|
1435
|
-
} else {
|
|
1436
|
-
logger.info('AIAgent', 'Stopping AudioInput...');
|
|
1437
|
-
audioInputRef.current?.stop();
|
|
1438
|
-
}
|
|
1439
|
-
},
|
|
1440
|
-
onSpeakerToggle: muted => {
|
|
1441
|
-
logger.info('AIAgent', `Speaker toggle: ${muted ? 'MUTED' : 'UNMUTED'}`);
|
|
1442
|
-
setIsSpeakerMuted(muted);
|
|
1443
|
-
if (muted) {
|
|
1444
|
-
audioOutputRef.current?.mute();
|
|
1445
|
-
} else {
|
|
1446
|
-
audioOutputRef.current?.unmute();
|
|
1447
|
-
}
|
|
1448
|
-
},
|
|
1449
|
-
tickets: tickets,
|
|
1450
|
-
selectedTicketId: selectedTicketId,
|
|
1451
|
-
onTicketSelect: handleTicketSelect,
|
|
1452
|
-
onBackToTickets: handleBackToTickets,
|
|
1453
|
-
autoExpandTrigger: autoExpandTrigger,
|
|
1454
|
-
unreadCounts: unreadCounts,
|
|
1455
|
-
totalUnread: totalUnread,
|
|
1456
|
-
showDiscoveryTooltip: tooltipVisible,
|
|
1457
|
-
onTooltipDismiss: handleTooltipDismiss
|
|
1458
|
-
})
|
|
1459
|
-
}), /*#__PURE__*/_jsx(SupportChatModal, {
|
|
1460
|
-
visible: mode === 'human' && !!selectedTicketId,
|
|
1461
|
-
messages: messages,
|
|
1462
|
-
onSend: handleSend,
|
|
1463
|
-
onClose: handleBackToTickets,
|
|
1464
|
-
isAgentTyping: isLiveAgentTyping,
|
|
1465
|
-
isThinking: isThinking,
|
|
1466
|
-
scrollToEndTrigger: chatScrollTrigger,
|
|
1467
|
-
ticketStatus: tickets.find(t => t.id === selectedTicketId)?.status
|
|
1468
|
-
})]
|
|
2228
|
+
onDecline: () => {
|
|
2229
|
+
setShowConsentDialog(false);
|
|
2230
|
+
consentConfig.onDecline?.();
|
|
2231
|
+
logger.info('AIAgent', '❌ AI consent declined by user');
|
|
2232
|
+
}
|
|
2233
|
+
})]
|
|
2234
|
+
})
|
|
1469
2235
|
})]
|
|
1470
2236
|
})
|
|
1471
2237
|
});
|