@oxvo/ai-live-assist 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/cjs/AiLiveAssist.d.ts +108 -0
  4. package/cjs/AiLiveAssist.js +1774 -0
  5. package/cjs/client.d.ts +53 -0
  6. package/cjs/client.js +193 -0
  7. package/cjs/context.d.ts +58 -0
  8. package/cjs/context.js +979 -0
  9. package/cjs/control.d.ts +31 -0
  10. package/cjs/control.js +190 -0
  11. package/cjs/experienceState.d.ts +18 -0
  12. package/cjs/experienceState.js +82 -0
  13. package/cjs/index.d.ts +13 -0
  14. package/cjs/index.js +32 -0
  15. package/cjs/media.d.ts +34 -0
  16. package/cjs/media.js +207 -0
  17. package/cjs/messages.d.ts +2 -0
  18. package/cjs/messages.js +95 -0
  19. package/cjs/package.json +1 -0
  20. package/cjs/placement.d.ts +52 -0
  21. package/cjs/placement.js +293 -0
  22. package/cjs/presentation.d.ts +41 -0
  23. package/cjs/presentation.js +483 -0
  24. package/cjs/recordingPolicy.d.ts +2 -0
  25. package/cjs/recordingPolicy.js +12 -0
  26. package/cjs/safeSvg.d.ts +1 -0
  27. package/cjs/safeSvg.js +157 -0
  28. package/cjs/tabLock.d.ts +31 -0
  29. package/cjs/tabLock.js +260 -0
  30. package/cjs/types.d.ts +299 -0
  31. package/cjs/types.js +2 -0
  32. package/cjs/ui.d.ts +184 -0
  33. package/cjs/ui.js +2353 -0
  34. package/cjs/version.d.ts +1 -0
  35. package/cjs/version.js +4 -0
  36. package/cjs/visualContext.d.ts +21 -0
  37. package/cjs/visualContext.js +72 -0
  38. package/cjs/voicePresenceUi.d.ts +148 -0
  39. package/cjs/voicePresenceUi.js +2182 -0
  40. package/lib/AiLiveAssist.d.ts +108 -0
  41. package/lib/AiLiveAssist.js +1769 -0
  42. package/lib/client.d.ts +53 -0
  43. package/lib/client.js +187 -0
  44. package/lib/context.d.ts +58 -0
  45. package/lib/context.js +975 -0
  46. package/lib/control.d.ts +31 -0
  47. package/lib/control.js +186 -0
  48. package/lib/experienceState.d.ts +18 -0
  49. package/lib/experienceState.js +78 -0
  50. package/lib/index.d.ts +13 -0
  51. package/lib/index.js +26 -0
  52. package/lib/media.d.ts +34 -0
  53. package/lib/media.js +203 -0
  54. package/lib/messages.d.ts +2 -0
  55. package/lib/messages.js +92 -0
  56. package/lib/placement.d.ts +52 -0
  57. package/lib/placement.js +286 -0
  58. package/lib/presentation.d.ts +41 -0
  59. package/lib/presentation.js +478 -0
  60. package/lib/recordingPolicy.d.ts +2 -0
  61. package/lib/recordingPolicy.js +8 -0
  62. package/lib/safeSvg.d.ts +1 -0
  63. package/lib/safeSvg.js +153 -0
  64. package/lib/tabLock.d.ts +31 -0
  65. package/lib/tabLock.js +256 -0
  66. package/lib/types.d.ts +299 -0
  67. package/lib/types.js +1 -0
  68. package/lib/ui.d.ts +184 -0
  69. package/lib/ui.js +2349 -0
  70. package/lib/version.d.ts +1 -0
  71. package/lib/version.js +1 -0
  72. package/lib/visualContext.d.ts +21 -0
  73. package/lib/visualContext.js +68 -0
  74. package/lib/voicePresenceUi.d.ts +148 -0
  75. package/lib/voicePresenceUi.js +2178 -0
  76. package/package.json +58 -0
@@ -0,0 +1,1769 @@
1
+ import { RuntimeClient, RuntimeClientError } from './client.js';
2
+ import { ControlChannel } from './control.js';
3
+ import { PageContextCollector } from './context.js';
4
+ import { RealtimeMedia } from './media.js';
5
+ import { PresentationPlane, parsePresentationIntent } from './presentation.js';
6
+ import { disableReplayRecording } from './recordingPolicy.js';
7
+ import { TabActivityLock } from './tabLock.js';
8
+ import { WidgetView } from './ui.js';
9
+ import { VERSION } from './version.js';
10
+ import { VoicePresenceView } from './voicePresenceUi.js';
11
+ const TERMINAL_STATES = new Set(['ended', 'failed', 'revoked']);
12
+ const ACTION_SCOPED_ERROR_CODES = new Set([
13
+ 'CONFIRMATION_EXPIRED',
14
+ 'STALE_TARGET',
15
+ 'TOKEN_REPLAYED',
16
+ ]);
17
+ const STORAGE_TTL_MS = 15 * 60 * 1000;
18
+ const APPROVAL_REPLIES = new Set([
19
+ 'apply',
20
+ 'apply it',
21
+ 'approve',
22
+ 'confirm',
23
+ 'continue',
24
+ 'do it',
25
+ 'go ahead',
26
+ 'proceed',
27
+ 'save',
28
+ 'save changes',
29
+ 'save it',
30
+ 'submit',
31
+ 'yes',
32
+ 'yes please',
33
+ ]);
34
+ const DECLINE_REPLIES = new Set([
35
+ 'cancel',
36
+ 'cancel it',
37
+ 'close',
38
+ 'close it',
39
+ 'decline',
40
+ 'do not do that',
41
+ 'do not save',
42
+ 'dont do that',
43
+ 'dont save',
44
+ 'never mind',
45
+ 'nevermind',
46
+ 'no',
47
+ 'no thanks',
48
+ 'reject',
49
+ 'stop',
50
+ ]);
51
+ export const confirmationReplyIntent = (value) => {
52
+ const normalized = value
53
+ .normalize('NFKC')
54
+ .toLocaleLowerCase('en')
55
+ .replace(/[\u2018\u2019']/gu, '')
56
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
57
+ .trim()
58
+ .replace(/\s+/gu, ' ');
59
+ if (!normalized || normalized.split(' ').length > 5)
60
+ return null;
61
+ if (DECLINE_REPLIES.has(normalized))
62
+ return 'decline';
63
+ if (APPROVAL_REPLIES.has(normalized))
64
+ return 'approve';
65
+ return null;
66
+ };
67
+ const boundedLocale = (value) => {
68
+ const normalized = value.replace('_', '-').slice(0, 16);
69
+ return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/.test(normalized)
70
+ ? normalized
71
+ : 'en';
72
+ };
73
+ const path = () => window.location.pathname || '/';
74
+ const randomId = (prefix) => `${prefix}_${crypto.randomUUID()}`;
75
+ const versionParts = (value) => {
76
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
77
+ if (!match)
78
+ return null;
79
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
80
+ };
81
+ const versionAtLeast = (current, minimum) => {
82
+ const left = versionParts(current);
83
+ const right = versionParts(minimum);
84
+ if (!left || !right)
85
+ return false;
86
+ for (let index = 0; index < left.length; index += 1) {
87
+ const currentPart = left[index] ?? 0;
88
+ const minimumPart = right[index] ?? 0;
89
+ if (currentPart !== minimumPart)
90
+ return currentPart > minimumPart;
91
+ }
92
+ return true;
93
+ };
94
+ const parseResumeRecord = (value) => {
95
+ if (!value)
96
+ return null;
97
+ try {
98
+ const record = JSON.parse(value);
99
+ if (typeof record.sessionId !== 'string' ||
100
+ typeof record.resumeCredential !== 'string' ||
101
+ !['text', 'voice'].includes(record.mode ?? '') ||
102
+ !Array.isArray(record.scopes) ||
103
+ typeof record.locale !== 'string' ||
104
+ typeof record.expiresAt !== 'number' ||
105
+ record.expiresAt <= Date.now()) {
106
+ return null;
107
+ }
108
+ return {
109
+ ...record,
110
+ tabId: typeof record.tabId === 'string' &&
111
+ /^[A-Za-z0-9_.:-]{8,128}$/.test(record.tabId)
112
+ ? record.tabId
113
+ : '',
114
+ clientSequence: Number.isSafeInteger(record.clientSequence) &&
115
+ (record.clientSequence ?? -1) >= 0
116
+ ? record.clientSequence
117
+ : 0,
118
+ visualConsentGranted: typeof record.visualConsentGranted === 'boolean'
119
+ ? record.visualConsentGranted
120
+ : record.scopes.includes('visual_context'),
121
+ manualTurn: record.manualTurn === true,
122
+ protocolVersion: record.protocolVersion === 2 ? 2 : 1,
123
+ experienceMode: record.experienceMode === 'voice_presence' ? 'voice_presence' : 'standard',
124
+ fallbackIntent: record.fallbackIntent === 'standard_assist' ||
125
+ record.fallbackIntent === 'human_handoff'
126
+ ? record.fallbackIntent
127
+ : null,
128
+ };
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ };
134
+ export default class AiLiveAssist {
135
+ constructor(app, options) {
136
+ this.app = app;
137
+ this.options = options;
138
+ this.version = VERSION;
139
+ this.bootstrap = null;
140
+ this.view = null;
141
+ this.result = null;
142
+ this.channel = null;
143
+ this.media = null;
144
+ this.context = null;
145
+ this.presentation = null;
146
+ this.connectionAbort = null;
147
+ this.scopes = [];
148
+ this.mode = 'text';
149
+ this.ended = false;
150
+ this.reconnecting = false;
151
+ this.pendingSessionAuthorization = null;
152
+ this.terminalRequestPending = false;
153
+ this.reconnectAttempts = 0;
154
+ this.heartbeatSeconds = 15;
155
+ this.pendingAction = false;
156
+ this.pendingConfirmation = null;
157
+ this.destroyed = false;
158
+ this.lastLaunch = null;
159
+ this.visualConsentGranted = false;
160
+ this.manualTurn = false;
161
+ this.pushActive = false;
162
+ this.activeFallback = null;
163
+ this.pendingHumanFallback = false;
164
+ this.pendingFocusedHandoff = null;
165
+ this.agentDiscoveryLink = null;
166
+ this.onPageHide = (event) => {
167
+ if (!this.result || this.ended)
168
+ return;
169
+ this.cleanupTransport();
170
+ if (!event.persisted)
171
+ this.lock.release();
172
+ };
173
+ this.onPageShow = (event) => {
174
+ if (!event.persisted || this.ended || this.channel?.isOpen)
175
+ return;
176
+ const resume = this.readResume();
177
+ if (resume && this.lock.acquire())
178
+ void this.resume(resume);
179
+ };
180
+ this.onWindowFocus = () => this.requestFocusedHandoff();
181
+ this.onVisibilityChange = () => {
182
+ if (document.visibilityState === 'visible')
183
+ this.requestFocusedHandoff();
184
+ };
185
+ this.onOffline = () => {
186
+ if (!this.ended && this.result) {
187
+ this.view?.showConnecting(true, this.mode);
188
+ this.cleanupTransport();
189
+ }
190
+ };
191
+ this.onOnline = () => this.scheduleReconnect();
192
+ this.onVisitorControl = (event) => {
193
+ if (event.isTrusted && !this.result && this.isVoicePresence()) {
194
+ this.requestFocusedHandoff();
195
+ }
196
+ if (!this.pendingAction)
197
+ return;
198
+ const eventPath = event.composedPath();
199
+ if (eventPath.some((target) => target instanceof HTMLElement &&
200
+ target.dataset.oxvoAiLiveAssistRoot === 'true')) {
201
+ return;
202
+ }
203
+ this.pendingAction = false;
204
+ if (this.view?.cancelPendingConfirmation())
205
+ return;
206
+ void this.updatePause(true);
207
+ };
208
+ if (options.recordReplay === false)
209
+ disableReplayRecording(app);
210
+ this.siteKey = options.siteKey || app.getProjectKey();
211
+ this.storageKey = `__oxvo_ai_live_assist_resume_${this.siteKey}`;
212
+ const resume = this.readResume();
213
+ this.tabId = resume?.tabId || app.session.getTabId() || randomId('tab');
214
+ this.windowId = randomId('window');
215
+ this.locale = boundedLocale(options.locale ||
216
+ document.documentElement.lang ||
217
+ navigator.language ||
218
+ 'en');
219
+ const endpoint = app.options.endpoint;
220
+ const runtimeUrl = options.runtimeUrl ||
221
+ (endpoint
222
+ ? new URL(endpoint, window.location.href).origin
223
+ : window.location.origin);
224
+ this.runtime = new RuntimeClient(runtimeUrl, VERSION);
225
+ this.lock = new TabActivityLock(this.siteKey, this.windowId, (active) => this.view?.showActiveElsewhere(active), (requesterId, requestId) => this.handoffToFocusedTab(requesterId, requestId), (payload) => void this.acceptFocusedTabHandoff(payload));
226
+ this.bindWindowLifecycle();
227
+ void this.initialize();
228
+ }
229
+ async end() {
230
+ if (this.ended)
231
+ return;
232
+ this.ended = true;
233
+ const result = this.result;
234
+ const pendingAuthorization = this.pendingSessionAuthorization;
235
+ const controlChannel = this.channel;
236
+ const fencingToken = this.channel?.leaseToken ?? 0;
237
+ const terminalMessageId = result?.protocolVersion === 2
238
+ ? this.send('experience.end', { reason: 'visitor_button' })
239
+ : null;
240
+ this.terminalRequestPending = Boolean(result || pendingAuthorization);
241
+ this.clearResume();
242
+ this.cleanupTransport({
243
+ preserveConnectionRequest: this.terminalRequestPending,
244
+ preserveControlChannel: Boolean(controlChannel),
245
+ });
246
+ this.lock.release();
247
+ this.view?.showEnded();
248
+ this.state('ended');
249
+ try {
250
+ let authorizedResult = result;
251
+ if (!authorizedResult && pendingAuthorization) {
252
+ try {
253
+ authorizedResult = await pendingAuthorization;
254
+ }
255
+ catch {
256
+ authorizedResult = null;
257
+ }
258
+ }
259
+ if (authorizedResult) {
260
+ await this.requestRuntimeEnd(authorizedResult, result ? fencingToken : 0, controlChannel, terminalMessageId);
261
+ }
262
+ }
263
+ finally {
264
+ this.terminalRequestPending = false;
265
+ this.cleanupTransport();
266
+ }
267
+ }
268
+ destroy() {
269
+ if (this.destroyed)
270
+ return;
271
+ if (this.result?.protocolVersion === 2 && !this.ended) {
272
+ this.send('experience.end', { reason: 'unmount' });
273
+ }
274
+ this.destroyed = true;
275
+ window.removeEventListener('pagehide', this.onPageHide);
276
+ window.removeEventListener('pageshow', this.onPageShow);
277
+ window.removeEventListener('online', this.onOnline);
278
+ window.removeEventListener('offline', this.onOffline);
279
+ window.removeEventListener('focus', this.onWindowFocus);
280
+ document.removeEventListener('visibilitychange', this.onVisibilityChange);
281
+ document.removeEventListener('pointerdown', this.onVisitorControl, true);
282
+ document.removeEventListener('keydown', this.onVisitorControl, true);
283
+ this.cleanupTransport({
284
+ preserveConnectionRequest: this.terminalRequestPending,
285
+ preserveControlChannel: this.terminalRequestPending,
286
+ });
287
+ this.lock.destroy();
288
+ this.agentDiscoveryLink?.remove();
289
+ this.agentDiscoveryLink = null;
290
+ this.view?.destroy();
291
+ this.view = null;
292
+ }
293
+ async initialize() {
294
+ if (this.options.enabled === false || this.destroyed)
295
+ return;
296
+ const controller = new AbortController();
297
+ const timeout = setTimeout(() => controller.abort(), Math.max(1000, Math.min(15000, this.options.bootstrapTimeoutMs ?? 15000)));
298
+ try {
299
+ const bootstrap = await this.runtime.bootstrap(this.siteKey, path(), this.visitorId(), controller.signal);
300
+ this.syncAgentDiscovery(bootstrap.agentAccess?.discoveryUrl ?? null);
301
+ if (bootstrap.replayRecordingEnabled === false) {
302
+ disableReplayRecording(this.app);
303
+ }
304
+ const experienceMode = bootstrap.experienceMode ?? 'standard';
305
+ const voicePresence = experienceMode === 'voice_presence';
306
+ const requestedProtocol = voicePresence ? 2 : 1;
307
+ const minimumProtocol = bootstrap.minimumProtocolVersion ?? requestedProtocol;
308
+ if (!bootstrap.available ||
309
+ bootstrap.protocol.min > requestedProtocol ||
310
+ bootstrap.protocol.max < requestedProtocol ||
311
+ minimumProtocol > requestedProtocol ||
312
+ !bootstrap.configRevision ||
313
+ !bootstrap.consent ||
314
+ !bootstrap.capabilities ||
315
+ (voicePresence &&
316
+ (!bootstrap.capabilities.voice ||
317
+ !bootstrap.capabilities.voicePresence ||
318
+ !bootstrap.appearance?.voicePresence?.enabled))) {
319
+ return;
320
+ }
321
+ if (bootstrap.widgetVersion &&
322
+ bootstrap.widgetVersion.split('.')[0] !== VERSION.split('.')[0]) {
323
+ return;
324
+ }
325
+ if (bootstrap.minimumClientVersion &&
326
+ !versionAtLeast(VERSION, bootstrap.minimumClientVersion)) {
327
+ return;
328
+ }
329
+ let resume = this.readResume();
330
+ const resumedFallback = resume?.experienceMode === 'standard' &&
331
+ resume.protocolVersion === 2 &&
332
+ resume.fallbackIntent !== null &&
333
+ this.fallbackAllowed(bootstrap, resume.fallbackIntent);
334
+ if (resume &&
335
+ !resumedFallback &&
336
+ (resume.experienceMode !== experienceMode ||
337
+ resume.protocolVersion !== requestedProtocol ||
338
+ resume.fallbackIntent !== null)) {
339
+ this.clearResume();
340
+ resume = null;
341
+ }
342
+ if (resumedFallback && resume) {
343
+ this.activeFallback = resume.fallbackIntent;
344
+ this.pendingHumanFallback = resume.fallbackIntent === 'human_handoff';
345
+ }
346
+ const effectiveBootstrap = resumedFallback
347
+ ? this.standardFallbackBootstrap(bootstrap)
348
+ : bootstrap;
349
+ this.bootstrap = effectiveBootstrap;
350
+ if (!resume &&
351
+ bootstrap.visitorEligibility?.status === 'visitor_limited' &&
352
+ bootstrap.appearance?.hideLauncherWhenVisitorLimited !== false) {
353
+ return;
354
+ }
355
+ const callbacks = this.widgetCallbacks();
356
+ const voicePresenceView = voicePresence &&
357
+ (!resumedFallback || resume?.fallbackIntent === 'human_handoff');
358
+ this.view = voicePresenceView
359
+ ? new VoicePresenceView(bootstrap, this.options.messages ?? {}, callbacks, this.tabId, (fallback) => void this.activateFallback(fallback), this.options.visualTestMode === true)
360
+ : new WidgetView(effectiveBootstrap, this.options.messages ?? {}, callbacks);
361
+ this.view.showActiveElsewhere(this.lock.isOwnedByOther());
362
+ if (!resume && bootstrap.visitorEligibility?.status !== 'eligible') {
363
+ const key = bootstrap.visitorEligibility?.status === 'visitor_limited'
364
+ ? 'visitorLimitReached'
365
+ : bootstrap.visitorEligibility?.status === 'cooldown_limited'
366
+ ? 'visitorCooldown'
367
+ : bootstrap.visitorEligibility?.status === 'visitor_active'
368
+ ? 'visitorAlreadyActive'
369
+ : null;
370
+ if (key && !(voicePresenceView && key === 'visitorAlreadyActive')) {
371
+ this.view.showLaunchUnavailable(this.view.message(key));
372
+ }
373
+ }
374
+ if (resume && this.lock.acquire()) {
375
+ this.view.showConnecting(true, resume.mode);
376
+ await this.resume(resume);
377
+ }
378
+ else if (voicePresenceView && !resume) {
379
+ this.requestFocusedHandoff();
380
+ }
381
+ }
382
+ catch (error) {
383
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
384
+ this.view?.showError(this.publicError(error), this.retryable(error));
385
+ }
386
+ }
387
+ finally {
388
+ clearTimeout(timeout);
389
+ }
390
+ }
391
+ syncAgentDiscovery(discoveryUrl) {
392
+ this.agentDiscoveryLink?.remove();
393
+ this.agentDiscoveryLink = null;
394
+ if (!discoveryUrl || !document.head)
395
+ return;
396
+ let parsed;
397
+ try {
398
+ parsed = new URL(discoveryUrl, window.location.href);
399
+ }
400
+ catch {
401
+ return;
402
+ }
403
+ if (!['http:', 'https:'].includes(parsed.protocol))
404
+ return;
405
+ const link = document.createElement('link');
406
+ link.rel = 'alternate';
407
+ link.type = 'application/vnd.oxvo.ai-assist+json';
408
+ link.href = parsed.href;
409
+ link.dataset.oxvoAiAgentDiscovery = 'true';
410
+ document.head.append(link);
411
+ this.agentDiscoveryLink = link;
412
+ }
413
+ widgetCallbacks() {
414
+ return {
415
+ onStart: (mode, scopes) => void this.launch(mode, scopes),
416
+ onEnd: () => void this.end(),
417
+ onMute: (muted) => this.updateMute(muted),
418
+ onInterrupt: () => this.interrupt(),
419
+ onVoiceTurnMode: (manual) => this.updateVoiceTurnMode(manual),
420
+ onPushToTalk: (active) => this.updatePushToTalk(active),
421
+ onPause: (paused) => void this.updatePause(paused),
422
+ onCaptions: (enabled) => this.updateCaptions(enabled),
423
+ onTextOnly: () => void this.switchToText(),
424
+ onText: (text) => void this.sendText(text),
425
+ onHuman: () => this.send('handoff.request', {
426
+ reason: 'The visitor requested a human support agent.',
427
+ }),
428
+ onConfirmation: (actionId, proposalChecksum, approved) => {
429
+ this.pendingConfirmation = null;
430
+ this.send('confirmation.result', {
431
+ actionId,
432
+ proposalChecksum,
433
+ approved,
434
+ });
435
+ if (!approved)
436
+ this.pendingAction = false;
437
+ },
438
+ onVisualContext: (request, approved) => {
439
+ void this.resolveVisualContext(request, approved, true);
440
+ },
441
+ onVisualPause: (paused) => this.updateVisualPause(paused),
442
+ onFeedback: (outcome) => void this.submitFeedback(outcome),
443
+ onRetry: () => void this.retry(),
444
+ onPlaybackState: (value) => {
445
+ if (this.result?.protocolVersion === 2) {
446
+ this.send('playback.state', value);
447
+ }
448
+ },
449
+ };
450
+ }
451
+ fallbackAllowed(bootstrap, intent) {
452
+ const configured = bootstrap.appearance?.voicePresence?.fallback;
453
+ if (intent === 'standard_assist') {
454
+ return (configured === 'offer_standard_assist' &&
455
+ Boolean(bootstrap.capabilities?.text || bootstrap.capabilities?.voice));
456
+ }
457
+ return (configured === 'offer_human_handoff' &&
458
+ bootstrap.capabilities?.handoff === true &&
459
+ bootstrap.capabilities.text);
460
+ }
461
+ standardFallbackBootstrap(bootstrap) {
462
+ if (!bootstrap.capabilities || !bootstrap.consent)
463
+ return bootstrap;
464
+ const requiredScopes = ['ai_disclosure', 'text'];
465
+ const optionalScopes = [
466
+ ...(bootstrap.consent?.requiredScopes ?? []),
467
+ ...(bootstrap.consent?.optionalScopes ?? []),
468
+ ].filter((scope, index, values) => scope !== 'ai_disclosure' &&
469
+ scope !== 'text' &&
470
+ scope !== 'presentation_guidance' &&
471
+ values.indexOf(scope) === index);
472
+ return {
473
+ ...bootstrap,
474
+ experienceMode: 'standard',
475
+ minimumProtocolVersion: 2,
476
+ capabilities: {
477
+ ...bootstrap.capabilities,
478
+ voicePresence: false,
479
+ presentation: false,
480
+ aiCursor: false,
481
+ annotations: false,
482
+ spatialCallouts: false,
483
+ },
484
+ consent: {
485
+ ...bootstrap.consent,
486
+ requiredScopes,
487
+ optionalScopes,
488
+ },
489
+ };
490
+ }
491
+ async activateFallback(intent) {
492
+ const bootstrap = this.bootstrap;
493
+ if (!bootstrap || !this.fallbackAllowed(bootstrap, intent))
494
+ return;
495
+ if (intent === 'human_handoff' &&
496
+ this.result?.capabilities?.handoff &&
497
+ this.channel?.isOpen) {
498
+ this.view?.setStatus(this.view.message('humanConnecting'));
499
+ this.send('handoff.request', {
500
+ reason: 'The visitor requested human support after voice assistance became unavailable.',
501
+ });
502
+ return;
503
+ }
504
+ if (this.result && !this.ended)
505
+ await this.end();
506
+ this.result = null;
507
+ this.lastLaunch = null;
508
+ this.activeFallback = intent;
509
+ this.pendingHumanFallback = intent === 'human_handoff';
510
+ this.ended = false;
511
+ const fallbackBootstrap = this.standardFallbackBootstrap(bootstrap);
512
+ this.bootstrap = fallbackBootstrap;
513
+ if (intent === 'standard_assist') {
514
+ this.view?.destroy();
515
+ this.view = new WidgetView(fallbackBootstrap, this.options.messages ?? {}, this.widgetCallbacks());
516
+ this.state('fallback_standard_offered');
517
+ return;
518
+ }
519
+ const scopes = [
520
+ ...(fallbackBootstrap.consent?.requiredScopes ?? []),
521
+ ...(fallbackBootstrap.consent?.optionalScopes ?? []),
522
+ ].filter((scope, index, values) => values.indexOf(scope) === index);
523
+ if (!scopes.includes('handoff_context')) {
524
+ this.view?.showError(this.view.message('humanUnavailable'), false, false);
525
+ return;
526
+ }
527
+ this.view?.setStatus(this.view.message('humanConnecting'));
528
+ await this.launch('text', scopes);
529
+ }
530
+ async launch(mode, scopes) {
531
+ const bootstrap = this.bootstrap;
532
+ if (!bootstrap?.configRevision || !bootstrap.consent || !this.view)
533
+ return;
534
+ const experienceMode = bootstrap.experienceMode ?? 'standard';
535
+ const voicePresence = experienceMode === 'voice_presence';
536
+ const protocolVersion = this.activeFallback ? 2 : voicePresence ? 2 : 1;
537
+ if (voicePresence && mode !== 'voice') {
538
+ this.view.showError('Voice assist requires microphone access.', false, false);
539
+ return;
540
+ }
541
+ if (!this.lock.acquire()) {
542
+ this.view.showActiveElsewhere(true);
543
+ return;
544
+ }
545
+ this.lastLaunch = { mode, scopes: [...scopes] };
546
+ let activeMode = mode;
547
+ let activeScopes = [...scopes];
548
+ this.manualTurn =
549
+ mode === 'voice' && bootstrap.voice?.turnMode === 'manual';
550
+ this.pushActive = false;
551
+ this.ended = false;
552
+ if (this.view instanceof VoicePresenceView) {
553
+ this.view.showRequestingMicrophone();
554
+ }
555
+ else {
556
+ this.view.showConnecting(false, mode);
557
+ }
558
+ this.state('requesting_access');
559
+ let runtimeSessionCreated = false;
560
+ let media = this.createMedia();
561
+ this.media = media;
562
+ try {
563
+ media.setManualTurn(this.manualTurn);
564
+ try {
565
+ await media.prepare(mode);
566
+ }
567
+ catch (error) {
568
+ if (voicePresence ||
569
+ mode !== 'voice' ||
570
+ !bootstrap.capabilities?.text) {
571
+ throw error;
572
+ }
573
+ media.close();
574
+ activeMode = 'text';
575
+ activeScopes = activeScopes.filter((scope) => scope !== 'microphone');
576
+ this.manualTurn = false;
577
+ media = this.createMedia();
578
+ this.media = media;
579
+ this.view.setStatus(this.view.message('microphoneUnavailable'));
580
+ }
581
+ if (voicePresence)
582
+ this.view.showConnecting(false, 'voice');
583
+ this.mode = activeMode;
584
+ this.scopes = activeScopes;
585
+ this.visualConsentGranted = activeScopes.includes('visual_context');
586
+ const controller = new AbortController();
587
+ this.connectionAbort = controller;
588
+ const authorization = this.runtime.launch({
589
+ siteKey: this.siteKey,
590
+ configRevision: bootstrap.configRevision,
591
+ path: path(),
592
+ tabId: this.tabId,
593
+ mode: activeMode,
594
+ anonymousId: this.visitorId(),
595
+ ...this.replayContext(),
596
+ disclosureChecksum: bootstrap.consent.disclosureChecksum,
597
+ scopes: activeScopes,
598
+ locale: this.locale,
599
+ microphone: activeMode === 'voice',
600
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
601
+ protocolVersion,
602
+ experienceMode,
603
+ ...(this.activeFallback
604
+ ? { fallbackIntent: this.activeFallback }
605
+ : {}),
606
+ lowPower: this.lowPowerMode(),
607
+ }, controller.signal);
608
+ this.pendingSessionAuthorization = authorization;
609
+ let result;
610
+ try {
611
+ result = await authorization;
612
+ }
613
+ finally {
614
+ if (this.pendingSessionAuthorization === authorization) {
615
+ this.pendingSessionAuthorization = null;
616
+ }
617
+ }
618
+ if (result.experienceMode !== experienceMode ||
619
+ result.protocolVersion !== protocolVersion) {
620
+ throw new RuntimeClientError('PROTOCOL_INVALID', 'The voice assist runtime returned an incompatible session.', false, null, 502);
621
+ }
622
+ this.result = result;
623
+ runtimeSessionCreated = true;
624
+ if (this.ended)
625
+ return;
626
+ this.writeResume(result, activeMode, activeScopes, undefined, 0);
627
+ await this.connectTransport(result, activeMode, activeScopes, false, controller, media);
628
+ this.reconnectAttempts = 0;
629
+ this.lastLaunch = null;
630
+ }
631
+ catch (error) {
632
+ this.cleanupTransport();
633
+ this.lock.release();
634
+ if (this.ended)
635
+ return;
636
+ if (!(await this.applyVisitorLimitFailure(error))) {
637
+ this.view?.showError(this.publicError(error), this.retryable(error), runtimeSessionCreated);
638
+ }
639
+ this.state('error');
640
+ }
641
+ }
642
+ async applyVisitorLimitFailure(error) {
643
+ if (!(error instanceof RuntimeClientError) || error.code !== 'RATE_LIMITED') {
644
+ return false;
645
+ }
646
+ const controller = new AbortController();
647
+ const timeout = setTimeout(() => controller.abort(), 5000);
648
+ try {
649
+ const bootstrap = await this.runtime.bootstrap(this.siteKey, path(), this.visitorId(), controller.signal);
650
+ this.bootstrap = bootstrap;
651
+ const status = bootstrap.visitorEligibility?.status;
652
+ if (status === 'visitor_limited') {
653
+ if (bootstrap.appearance?.hideLauncherWhenVisitorLimited !== false) {
654
+ this.view?.destroy();
655
+ this.view = null;
656
+ return true;
657
+ }
658
+ this.view?.showLaunchUnavailable(this.view.message('visitorLimitReached'));
659
+ return true;
660
+ }
661
+ if (status === 'cooldown_limited') {
662
+ this.view?.showLaunchUnavailable(this.view.message('visitorCooldown'));
663
+ return true;
664
+ }
665
+ }
666
+ catch {
667
+ // Preserve the original launch error when eligibility refresh is unavailable.
668
+ }
669
+ finally {
670
+ clearTimeout(timeout);
671
+ }
672
+ return false;
673
+ }
674
+ async resume(record) {
675
+ if (this.reconnecting || this.destroyed)
676
+ return;
677
+ this.reconnecting = true;
678
+ let connected = false;
679
+ this.mode = record.mode;
680
+ this.scopes = [...record.scopes];
681
+ this.visualConsentGranted = record.visualConsentGranted;
682
+ this.manualTurn =
683
+ record.mode === 'voice' &&
684
+ (record.manualTurn || this.bootstrap?.voice?.turnMode === 'manual');
685
+ this.pushActive = false;
686
+ this.state('reconnecting');
687
+ const controller = new AbortController();
688
+ this.connectionAbort = controller;
689
+ try {
690
+ const authorization = this.runtime.resume(record.sessionId, record.resumeCredential, path(), record.protocolVersion, controller.signal);
691
+ this.pendingSessionAuthorization = authorization;
692
+ let result;
693
+ try {
694
+ result = await authorization;
695
+ }
696
+ finally {
697
+ if (this.pendingSessionAuthorization === authorization) {
698
+ this.pendingSessionAuthorization = null;
699
+ }
700
+ }
701
+ if (result.protocolVersion !== record.protocolVersion ||
702
+ result.experienceMode !== record.experienceMode) {
703
+ throw new RuntimeClientError('PROTOCOL_INVALID', 'The saved voice assist session is no longer compatible.', false, null, 409);
704
+ }
705
+ this.result = result;
706
+ if (this.ended)
707
+ return;
708
+ this.writeResume(result, record.mode, record.scopes, record.visualConsentGranted, record.clientSequence);
709
+ const media = this.createMedia();
710
+ this.media = media;
711
+ media.setManualTurn(this.manualTurn);
712
+ await media.prepare(record.mode);
713
+ await this.connectTransport(result, record.mode, record.scopes, true, controller, media);
714
+ this.reconnectAttempts = 0;
715
+ connected = true;
716
+ }
717
+ catch (error) {
718
+ this.clearResume();
719
+ this.cleanupTransport();
720
+ this.lock.release();
721
+ if (this.ended)
722
+ return;
723
+ this.view?.showError(this.publicError(error), this.retryable(error));
724
+ this.state('error');
725
+ }
726
+ finally {
727
+ this.reconnecting = false;
728
+ const pending = this.pendingFocusedHandoff;
729
+ if (connected && pending && !this.ended && this.result) {
730
+ this.pendingFocusedHandoff = null;
731
+ this.handoffToFocusedTab(pending.requesterId, pending.requestId);
732
+ }
733
+ }
734
+ }
735
+ async connectTransport(result, mode, scopes, resume, controller, media) {
736
+ const channel = new ControlChannel(result, this.tabId, VERSION, () => this.scheduleReconnect(), this.readResume()?.clientSequence ?? 0, (sequence) => this.updateResumeSequence(sequence));
737
+ this.channel = channel;
738
+ channel.subscribe((message) => this.handleServerMessage(message));
739
+ await channel.connect(controller.signal, resume);
740
+ const fencingToken = await media.connect(result, mode, controller.signal);
741
+ channel.setFencingToken(fencingToken);
742
+ channel.startHeartbeat(this.heartbeatSeconds);
743
+ channel.send('consent.update', { scopes, locale: this.locale });
744
+ channel.send('media.state', {
745
+ microphone: mode === 'voice' ? 'active' : 'unavailable',
746
+ playback: mode === 'voice' ? 'active' : 'paused',
747
+ captions: true,
748
+ });
749
+ if (mode === 'voice' &&
750
+ this.manualTurn &&
751
+ this.bootstrap?.voice?.turnMode !== 'manual') {
752
+ channel.send('voice.turn_mode', { mode: 'manual' });
753
+ }
754
+ this.startContext(scopes);
755
+ this.startPresentation(result, scopes);
756
+ if (this.view instanceof VoicePresenceView) {
757
+ this.view.setCapabilityTier(result.capabilityTier);
758
+ }
759
+ this.view?.setVisualAvailable(this.visualConsentGranted, !scopes.includes('visual_context'));
760
+ this.view?.configureVoiceControls(mode === 'voice', this.manualTurn, this.bootstrap?.voice?.turnMode !== 'manual');
761
+ const welcomeMessage = this.pendingHumanFallback
762
+ ? null
763
+ : result.assistant?.welcomeMessage ??
764
+ this.bootstrap?.appearance?.welcomeMessage;
765
+ this.view?.showActive(this.view instanceof VoicePresenceView && mode === 'voice'
766
+ ? null
767
+ : welcomeMessage);
768
+ this.state(mode === 'voice' ? 'active_voice' : 'active_text');
769
+ if (this.pendingHumanFallback) {
770
+ this.pendingHumanFallback = false;
771
+ if (result.capabilities?.handoff) {
772
+ this.view?.setStatus(this.view.message('humanConnecting'));
773
+ this.send('handoff.request', {
774
+ reason: 'The visitor chose human support because voice assistance was unavailable.',
775
+ });
776
+ }
777
+ else {
778
+ this.view?.showError(this.view.message('humanUnavailable'), false);
779
+ }
780
+ }
781
+ }
782
+ startContext(scopes) {
783
+ this.context?.stop();
784
+ this.context = null;
785
+ if (!scopes.includes('page_context') ||
786
+ !this.bootstrap?.capabilities?.pageContext) {
787
+ return;
788
+ }
789
+ this.context = new PageContextCollector({
790
+ onSnapshot: (snapshot) => {
791
+ if (this.channel?.leaseToken)
792
+ this.send('context.snapshot', snapshot);
793
+ },
794
+ onNavigation: (pageId, revision, url) => {
795
+ this.presentation?.clear('navigation');
796
+ this.send('context.navigation', {
797
+ pageId,
798
+ revision,
799
+ url,
800
+ navigationType: 'spa',
801
+ });
802
+ },
803
+ onHighlight: (element, status) => this.view?.updateHighlight(element, status),
804
+ }, this.bootstrap.safety?.selectorRegions ?? [], this.bootstrap.safety?.policyVersion ?? 1);
805
+ this.context.start();
806
+ }
807
+ startPresentation(result, scopes) {
808
+ this.presentation?.destroy();
809
+ this.presentation = null;
810
+ const voice = this.bootstrap?.appearance?.voicePresence;
811
+ if (result.protocolVersion !== 2 ||
812
+ result.experienceMode !== 'voice_presence' ||
813
+ result.capabilities?.presentation !== true ||
814
+ !scopes.includes('presentation_guidance') ||
815
+ !this.context ||
816
+ !voice) {
817
+ return;
818
+ }
819
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
820
+ const presentationConfig = voice.presentation;
821
+ this.presentation = new PresentationPlane(this.bootstrap?.appearance?.accent ?? '#2563eb', voice.presentation.maxActiveCues, reducedMotion || result.capabilityTier !== 'standard', {
822
+ resolveTarget: (targetId, revision) => this.context?.presentationTarget(targetId, revision) ?? null,
823
+ currentPage: () => ({
824
+ pageId: this.context?.currentPageId ?? 'page_unknown',
825
+ revision: this.context?.currentRevision ?? 0,
826
+ }),
827
+ onResult: (value) => this.sendPresentationResult(value),
828
+ onVisibility: (pageId, domRevision, visiblePresentationIds) => {
829
+ this.send('presentation.visibility', {
830
+ pageId,
831
+ domRevision,
832
+ visiblePresentationIds,
833
+ documentVisible: !document.hidden,
834
+ });
835
+ },
836
+ launcherRect: () => this.view instanceof VoicePresenceView ? this.view.launcherRect() : null,
837
+ obstacleRects: () => this.view instanceof VoicePresenceView
838
+ ? this.view.presentationObstacles()
839
+ : [],
840
+ spatialTrail: voice.visual.spatialTrail,
841
+ kindEnabled: (kind) => {
842
+ if (kind === 'clear')
843
+ return true;
844
+ if (kind === 'pointer') {
845
+ return (presentationConfig.aiCursor && result.capabilities?.aiCursor !== false);
846
+ }
847
+ if (kind === 'spotlight')
848
+ return presentationConfig.spotlight;
849
+ if (['underline', 'circle', 'arrow', 'connector'].includes(kind)) {
850
+ return (presentationConfig.annotations &&
851
+ result.capabilities?.annotations !== false);
852
+ }
853
+ if (kind === 'sequence_marker')
854
+ return presentationConfig.sequenceMarkers;
855
+ return (kind === 'callout' &&
856
+ presentationConfig.spatialCallouts &&
857
+ result.capabilities?.spatialCallouts !== false);
858
+ },
859
+ });
860
+ }
861
+ async sendText(text) {
862
+ if (this.resolvePendingConfirmationReply(text))
863
+ return;
864
+ const supersedesConfirmation = this.pendingConfirmation !== null;
865
+ await this.context?.captureAndPublish();
866
+ if (this.ended)
867
+ return;
868
+ this.send('text.message', { text });
869
+ if (supersedesConfirmation)
870
+ this.cancelConfirmationForNewRequest();
871
+ }
872
+ resolvePendingConfirmationReply(text) {
873
+ const pending = this.pendingConfirmation;
874
+ if (!pending)
875
+ return false;
876
+ const intent = confirmationReplyIntent(text);
877
+ if (!intent)
878
+ return false;
879
+ if (this.mode === 'voice')
880
+ this.interrupt();
881
+ const approved = intent === 'approve';
882
+ if (this.view?.resolvePendingConfirmation(approved))
883
+ return true;
884
+ this.pendingConfirmation = null;
885
+ this.send('confirmation.result', {
886
+ actionId: pending.actionId,
887
+ proposalChecksum: pending.proposalChecksum,
888
+ approved,
889
+ });
890
+ if (!approved)
891
+ this.pendingAction = false;
892
+ return true;
893
+ }
894
+ cancelConfirmationForNewRequest() {
895
+ const pending = this.pendingConfirmation;
896
+ if (!pending)
897
+ return;
898
+ if (this.mode === 'voice')
899
+ this.interrupt();
900
+ if (this.view?.resolvePendingConfirmation(false))
901
+ return;
902
+ this.pendingConfirmation = null;
903
+ this.pendingAction = false;
904
+ this.send('confirmation.result', {
905
+ actionId: pending.actionId,
906
+ proposalChecksum: pending.proposalChecksum,
907
+ approved: false,
908
+ });
909
+ }
910
+ async handleServerMessage(message) {
911
+ const payload = message.payload;
912
+ switch (message.type) {
913
+ case 'control.ready':
914
+ if (typeof payload.heartbeatSeconds === 'number') {
915
+ this.heartbeatSeconds = payload.heartbeatSeconds;
916
+ if (this.channel?.leaseToken) {
917
+ this.channel.startHeartbeat(this.heartbeatSeconds);
918
+ }
919
+ }
920
+ return;
921
+ case 'presence.state': {
922
+ const state = this.string(payload.state);
923
+ if (this.view instanceof VoicePresenceView &&
924
+ [
925
+ 'ready',
926
+ 'listening',
927
+ 'thinking',
928
+ 'speaking',
929
+ 'acting',
930
+ 'awaiting_confirmation',
931
+ 'reconnecting',
932
+ 'ending',
933
+ ].includes(state) &&
934
+ this.result) {
935
+ this.view.applyPresenceState(state, message.sequence, message.sessionId, this.context?.currentPageId ?? null, this.channel?.leaseToken ?? 0);
936
+ }
937
+ return;
938
+ }
939
+ case 'caption.segment': {
940
+ const text = this.string(payload.text);
941
+ const speaker = payload.speaker === 'visitor' ? 'user' : 'assistant';
942
+ if (text) {
943
+ this.view?.addCaption(speaker, text, payload.final === true);
944
+ if (speaker === 'user' && payload.final === true) {
945
+ if (!this.resolvePendingConfirmationReply(text)) {
946
+ this.cancelConfirmationForNewRequest();
947
+ }
948
+ }
949
+ }
950
+ return;
951
+ }
952
+ case 'presentation.intent':
953
+ case 'presentation.clear': {
954
+ const intent = parsePresentationIntent(payload);
955
+ if (!intent || !this.presentation) {
956
+ if (intent) {
957
+ this.sendPresentationResult({
958
+ presentationId: intent.presentationId,
959
+ status: 'unsupported',
960
+ reasonCode: 'PRESENTATION_DISABLED',
961
+ pageId: this.context?.currentPageId ?? 'page_unknown',
962
+ domRevision: intent.domRevision,
963
+ shownAt: null,
964
+ });
965
+ }
966
+ return;
967
+ }
968
+ this.sendPresentationResult(this.presentation.render(intent));
969
+ return;
970
+ }
971
+ case 'experience.notice': {
972
+ const code = this.string(payload.code);
973
+ if (code === 'AUDIO_PLAYBACK_BLOCKED') {
974
+ this.view?.setStatus('Tap the voice button to resume audio.');
975
+ }
976
+ return;
977
+ }
978
+ case 'assistant.state': {
979
+ const state = payload.state;
980
+ if (state === 'listening' ||
981
+ state === 'thinking' ||
982
+ state === 'ready') {
983
+ this.view?.setAssistantState(state);
984
+ }
985
+ return;
986
+ }
987
+ case 'assistant.caption':
988
+ case 'user.caption': {
989
+ const text = typeof payload.text === 'string' ? payload.text : '';
990
+ if (!text)
991
+ return;
992
+ const role = message.type.startsWith('assistant')
993
+ ? 'assistant'
994
+ : 'user';
995
+ this.view?.addCaption(role, text, payload.final === true);
996
+ if (role === 'user' && payload.final === true) {
997
+ if (!this.resolvePendingConfirmationReply(text)) {
998
+ this.cancelConfirmationForNewRequest();
999
+ }
1000
+ }
1001
+ if (role === 'assistant' && message.v === 1) {
1002
+ this.view?.setAssistantState('speaking');
1003
+ }
1004
+ return;
1005
+ }
1006
+ case 'assistant.interrupted':
1007
+ this.presentation?.clear('all');
1008
+ this.media?.endPushToTalk();
1009
+ this.pushActive = false;
1010
+ this.view?.interruptPlayback();
1011
+ return;
1012
+ case 'voice.turn_mode': {
1013
+ const manual = payload.mode === 'manual';
1014
+ this.manualTurn = manual;
1015
+ this.pushActive = false;
1016
+ this.media?.setManualTurn(manual);
1017
+ this.view?.setManualTurn(manual);
1018
+ this.updateResumeManualTurn();
1019
+ return;
1020
+ }
1021
+ case 'voice.push.ready':
1022
+ if (!this.manualTurn)
1023
+ return;
1024
+ this.pushActive = true;
1025
+ this.media?.beginPushToTalk();
1026
+ this.view?.setPushActive(true);
1027
+ return;
1028
+ case 'voice.push.committed':
1029
+ this.pushActive = false;
1030
+ this.view?.setPushActive(false);
1031
+ return;
1032
+ case 'confirmation.request': {
1033
+ this.presentation?.clear('all');
1034
+ const actionId = this.string(payload.actionId);
1035
+ const proposalChecksum = this.string(payload.proposalChecksum);
1036
+ const expiresAt = this.string(payload.expiresAt);
1037
+ if (!actionId || !proposalChecksum || !expiresAt)
1038
+ return;
1039
+ this.pendingAction = true;
1040
+ this.pendingConfirmation = { actionId, proposalChecksum };
1041
+ this.view?.showConfirmation({
1042
+ actionId,
1043
+ proposalChecksum,
1044
+ action: this.string(payload.action) ||
1045
+ this.string(payload.title) ||
1046
+ this.view?.message('connectedTool') || '',
1047
+ targetLabel: this.string(payload.targetLabel) ||
1048
+ this.string(payload.title) ||
1049
+ this.view?.message('approvedService') || '',
1050
+ reason: this.string(payload.reason) || this.string(payload.description),
1051
+ expectedEffect: this.string(payload.expectedEffect) ||
1052
+ this.view?.message('approvedToolEffect') || '',
1053
+ expiresAt,
1054
+ });
1055
+ void this.confirmSafetySurface(actionId, proposalChecksum);
1056
+ return;
1057
+ }
1058
+ case 'action.grant':
1059
+ await this.executeGrant(payload);
1060
+ return;
1061
+ case 'action.verified':
1062
+ this.pendingAction = false;
1063
+ this.view?.showActionState(payload.verified === true ? 'verified' : 'failed');
1064
+ return;
1065
+ case 'action.revoked':
1066
+ this.pendingAction = false;
1067
+ this.pendingConfirmation = null;
1068
+ this.view?.dismissActionState();
1069
+ return;
1070
+ case 'context.resync_required':
1071
+ await this.context?.captureAndPublish();
1072
+ return;
1073
+ case 'visual_context.request': {
1074
+ const requestId = this.string(payload.requestId);
1075
+ const functionCallId = this.string(payload.functionCallId);
1076
+ const expiresAt = this.string(payload.expiresAt);
1077
+ const expiresAtMs = Date.parse(expiresAt);
1078
+ const approvalMode = payload.approvalMode === 'automatic' ? 'automatic' : 'manual';
1079
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(requestId) ||
1080
+ !functionCallId ||
1081
+ functionCallId.length > 256 ||
1082
+ !Number.isFinite(expiresAtMs) ||
1083
+ expiresAtMs <= Date.now() ||
1084
+ expiresAtMs > Date.now() + 90000) {
1085
+ return;
1086
+ }
1087
+ const request = {
1088
+ requestId,
1089
+ functionCallId,
1090
+ reason: this.string(payload.reason).slice(0, 500),
1091
+ expiresAt,
1092
+ approvalMode,
1093
+ };
1094
+ if (!this.scopes.includes('visual_context') ||
1095
+ !this.scopes.includes('page_context') ||
1096
+ !this.bootstrap?.capabilities?.visualContext ||
1097
+ !this.context) {
1098
+ this.send('visual_context.result', {
1099
+ requestId,
1100
+ functionCallId,
1101
+ approved: false,
1102
+ reasonCode: 'CAPTURE_UNAVAILABLE',
1103
+ });
1104
+ return;
1105
+ }
1106
+ if (approvalMode === 'automatic') {
1107
+ void this.resolveVisualContext(request, true, false);
1108
+ }
1109
+ else {
1110
+ this.view?.showVisualRequest(request);
1111
+ }
1112
+ return;
1113
+ }
1114
+ case 'session.state': {
1115
+ const state = this.string(payload.state);
1116
+ if (state === 'paused')
1117
+ this.view?.setPaused(true);
1118
+ if (state === 'active_text' || state === 'active_voice') {
1119
+ this.view?.setPaused(false);
1120
+ }
1121
+ if (TERMINAL_STATES.has(state)) {
1122
+ this.finishFromServer(this.string(payload.reasonCode));
1123
+ }
1124
+ return;
1125
+ }
1126
+ case 'provider.reconnecting':
1127
+ this.view?.showConnecting(true, this.mode);
1128
+ this.state('reconnecting');
1129
+ return;
1130
+ case 'provider.warning':
1131
+ this.view?.setStatus(this.string(payload.message) || this.view.message('error'));
1132
+ return;
1133
+ case 'limit.reached':
1134
+ this.view?.showLimit(this.string(payload.message));
1135
+ return;
1136
+ case 'handoff.state': {
1137
+ const state = this.string(payload.state);
1138
+ const configuredMessage = this.string(payload.message);
1139
+ this.view?.setStatus(state === 'connected' && configuredMessage
1140
+ ? configuredMessage
1141
+ : state === 'connected'
1142
+ ? this.view.message('humanConnected')
1143
+ : state === 'connecting'
1144
+ ? this.view.message('humanConnecting')
1145
+ : this.view.message('humanUnavailable'));
1146
+ if (state === 'connected') {
1147
+ this.pendingAction = false;
1148
+ this.presentation?.clear('all');
1149
+ }
1150
+ return;
1151
+ }
1152
+ case 'error': {
1153
+ const code = this.string(payload.code);
1154
+ if (this.pendingAction && ACTION_SCOPED_ERROR_CODES.has(code)) {
1155
+ this.pendingAction = false;
1156
+ this.pendingConfirmation = null;
1157
+ this.view?.showActionState('failed');
1158
+ await this.context?.captureAndPublish();
1159
+ return;
1160
+ }
1161
+ const retryable = payload.retryable === true;
1162
+ this.view?.showError(this.string(payload.message), retryable);
1163
+ if (!retryable)
1164
+ this.clearResume();
1165
+ return;
1166
+ }
1167
+ }
1168
+ }
1169
+ async executeGrant(payload) {
1170
+ const grant = this.parseGrant(payload);
1171
+ if (!grant || !this.context || !this.channel)
1172
+ return;
1173
+ this.pendingAction = true;
1174
+ this.view?.showActionState('running');
1175
+ let result;
1176
+ if (!this.view?.safetySurfaceVisible()) {
1177
+ result = {
1178
+ grant: grant.grant,
1179
+ actionId: grant.actionId,
1180
+ status: 'blocked',
1181
+ observed: {
1182
+ url: `${location.origin}${location.pathname}`,
1183
+ revision: this.context.currentRevision,
1184
+ targetState: 'unchanged',
1185
+ safeSummary: 'The isolated safety controls were not visible.',
1186
+ },
1187
+ };
1188
+ }
1189
+ else {
1190
+ result = await this.context.execute(grant);
1191
+ }
1192
+ const channel = this.channel;
1193
+ const messageId = this.send('action.result', result);
1194
+ const deferredNavigation = await this.context.completeActionExecution(grant.actionId);
1195
+ if (!deferredNavigation || !channel || !messageId)
1196
+ return;
1197
+ const acknowledged = await channel.waitForAck(messageId);
1198
+ if (acknowledged &&
1199
+ channel === this.channel &&
1200
+ !this.ended &&
1201
+ new URL(deferredNavigation).origin === window.location.origin) {
1202
+ window.location.assign(deferredNavigation);
1203
+ }
1204
+ else if (!acknowledged) {
1205
+ this.view?.showActionState('failed');
1206
+ }
1207
+ }
1208
+ async confirmSafetySurface(actionId, proposalChecksum) {
1209
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1210
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
1211
+ if (this.pendingConfirmation?.actionId !== actionId ||
1212
+ this.pendingConfirmation.proposalChecksum !== proposalChecksum) {
1213
+ return;
1214
+ }
1215
+ if (this.view?.safetySurfaceVisible())
1216
+ return;
1217
+ }
1218
+ if (this.pendingConfirmation?.actionId !== actionId ||
1219
+ this.pendingConfirmation.proposalChecksum !== proposalChecksum) {
1220
+ return;
1221
+ }
1222
+ this.pendingAction = false;
1223
+ this.view?.cancelPendingConfirmation();
1224
+ }
1225
+ async resolveVisualContext(request, approved, requireVisibleSafetySurface) {
1226
+ const showManualResult = request.approvalMode === 'manual';
1227
+ if (!approved) {
1228
+ this.send('visual_context.result', {
1229
+ requestId: request.requestId,
1230
+ functionCallId: request.functionCallId,
1231
+ approved: false,
1232
+ reasonCode: 'VISITOR_DECLINED',
1233
+ });
1234
+ if (showManualResult)
1235
+ this.view?.showVisualResult(false);
1236
+ return;
1237
+ }
1238
+ if (Date.parse(request.expiresAt) <= Date.now()) {
1239
+ this.send('visual_context.result', {
1240
+ requestId: request.requestId,
1241
+ functionCallId: request.functionCallId,
1242
+ approved: false,
1243
+ reasonCode: 'REQUEST_EXPIRED',
1244
+ });
1245
+ if (showManualResult)
1246
+ this.view?.showVisualResult(false);
1247
+ return;
1248
+ }
1249
+ if (requireVisibleSafetySurface && !this.view?.safetySurfaceVisible()) {
1250
+ this.send('visual_context.result', {
1251
+ requestId: request.requestId,
1252
+ functionCallId: request.functionCallId,
1253
+ approved: false,
1254
+ reasonCode: 'SAFETY_SURFACE_HIDDEN',
1255
+ });
1256
+ if (showManualResult)
1257
+ this.view?.showVisualResult(false);
1258
+ return;
1259
+ }
1260
+ const context = this.context;
1261
+ if (!context) {
1262
+ this.send('visual_context.result', {
1263
+ requestId: request.requestId,
1264
+ functionCallId: request.functionCallId,
1265
+ approved: false,
1266
+ reasonCode: 'CAPTURE_UNAVAILABLE',
1267
+ });
1268
+ if (showManualResult)
1269
+ this.view?.showVisualResult(false);
1270
+ return;
1271
+ }
1272
+ await context.captureAndPublish();
1273
+ if (Date.parse(request.expiresAt) <= Date.now()) {
1274
+ this.send('visual_context.result', {
1275
+ requestId: request.requestId,
1276
+ functionCallId: request.functionCallId,
1277
+ approved: false,
1278
+ reasonCode: 'REQUEST_EXPIRED',
1279
+ });
1280
+ if (showManualResult)
1281
+ this.view?.showVisualResult(false);
1282
+ return;
1283
+ }
1284
+ const capture = context.captureVisualContext();
1285
+ if (!capture) {
1286
+ this.send('visual_context.result', {
1287
+ requestId: request.requestId,
1288
+ functionCallId: request.functionCallId,
1289
+ approved: false,
1290
+ reasonCode: 'CAPTURE_UNAVAILABLE',
1291
+ });
1292
+ if (showManualResult)
1293
+ this.view?.showVisualResult(false);
1294
+ return;
1295
+ }
1296
+ this.send('visual_context.result', {
1297
+ requestId: request.requestId,
1298
+ functionCallId: request.functionCallId,
1299
+ approved: true,
1300
+ capture,
1301
+ });
1302
+ if (showManualResult)
1303
+ this.view?.showVisualResult(true);
1304
+ }
1305
+ parseGrant(payload) {
1306
+ const action = this.string(payload.action);
1307
+ if (![
1308
+ 'scroll',
1309
+ 'highlight',
1310
+ 'focus',
1311
+ 'click',
1312
+ 'type',
1313
+ 'select',
1314
+ 'submit',
1315
+ ].includes(action)) {
1316
+ return null;
1317
+ }
1318
+ const grant = this.string(payload.grant);
1319
+ const actionId = this.string(payload.actionId);
1320
+ const targetId = this.string(payload.targetId);
1321
+ const expiresAt = this.string(payload.expiresAt);
1322
+ const domRevision = payload.domRevision;
1323
+ const fencingToken = payload.fencingToken;
1324
+ if (!grant ||
1325
+ !actionId ||
1326
+ !targetId ||
1327
+ !expiresAt ||
1328
+ typeof domRevision !== 'number' ||
1329
+ typeof fencingToken !== 'number' ||
1330
+ fencingToken !== this.channel?.leaseToken) {
1331
+ return null;
1332
+ }
1333
+ return {
1334
+ grant,
1335
+ actionId,
1336
+ action: action,
1337
+ targetId,
1338
+ domRevision,
1339
+ fencingToken,
1340
+ expiresAt,
1341
+ ...(typeof payload.value === 'string' ? { value: payload.value } : {}),
1342
+ ...(typeof payload.optionValueId === 'string'
1343
+ ? { optionValueId: payload.optionValueId }
1344
+ : {}),
1345
+ ...(typeof payload.desiredChecked === 'boolean'
1346
+ ? { desiredChecked: payload.desiredChecked }
1347
+ : {}),
1348
+ };
1349
+ }
1350
+ updateMute(muted) {
1351
+ this.media?.setMuted(muted);
1352
+ this.send('media.state', {
1353
+ microphone: muted
1354
+ ? 'muted'
1355
+ : this.mode === 'voice'
1356
+ ? 'active'
1357
+ : 'unavailable',
1358
+ playback: this.mode === 'voice' ? 'active' : 'paused',
1359
+ captions: true,
1360
+ });
1361
+ }
1362
+ interrupt() {
1363
+ if (this.mode !== 'voice')
1364
+ return;
1365
+ this.presentation?.clear('all');
1366
+ this.media?.endPushToTalk();
1367
+ this.pushActive = false;
1368
+ this.view?.setPushActive(false);
1369
+ this.send('session.interrupt', {});
1370
+ }
1371
+ updateVoiceTurnMode(manual) {
1372
+ if (this.mode !== 'voice' || !this.channel?.isOpen)
1373
+ return;
1374
+ if (!manual && this.bootstrap?.voice?.turnMode === 'manual')
1375
+ return;
1376
+ if (this.pushActive) {
1377
+ this.media?.endPushToTalk();
1378
+ this.pushActive = false;
1379
+ this.view?.setPushActive(false);
1380
+ }
1381
+ this.send('voice.turn_mode', { mode: manual ? 'manual' : 'configured' });
1382
+ }
1383
+ updatePushToTalk(active) {
1384
+ if (this.mode !== 'voice' ||
1385
+ !this.manualTurn ||
1386
+ !this.channel?.isOpen) {
1387
+ return;
1388
+ }
1389
+ if (active) {
1390
+ if (!this.pushActive)
1391
+ this.send('voice.push.start', {});
1392
+ return;
1393
+ }
1394
+ if (!this.pushActive)
1395
+ return;
1396
+ this.media?.endPushToTalk();
1397
+ this.pushActive = false;
1398
+ this.view?.setPushActive(false);
1399
+ this.send('voice.push.commit', {});
1400
+ }
1401
+ async updatePause(paused) {
1402
+ if (!this.result || !this.channel?.leaseToken)
1403
+ return;
1404
+ try {
1405
+ this.send(paused ? 'session.pause' : 'session.resume', {
1406
+ actions: true,
1407
+ });
1408
+ }
1409
+ catch {
1410
+ await this.runtime.pauseOrResume(this.result, this.channel.leaseToken, paused ? 'pause' : 'resume');
1411
+ }
1412
+ }
1413
+ updateCaptions(enabled) {
1414
+ this.send('media.state', {
1415
+ microphone: this.mode === 'voice' ? 'active' : 'unavailable',
1416
+ playback: this.mode === 'voice' ? 'active' : 'paused',
1417
+ captions: enabled,
1418
+ });
1419
+ }
1420
+ updateVisualPause(paused) {
1421
+ if (!this.visualConsentGranted || !this.channel?.isOpen)
1422
+ return;
1423
+ this.scopes = paused
1424
+ ? this.scopes.filter((scope) => scope !== 'visual_context')
1425
+ : [...new Set([...this.scopes, 'visual_context'])];
1426
+ this.send('consent.update', { scopes: this.scopes, locale: this.locale });
1427
+ const record = this.readResume();
1428
+ if (record) {
1429
+ this.storeResume({ ...record, scopes: [...this.scopes] });
1430
+ }
1431
+ }
1432
+ async switchToText() {
1433
+ if (this.mode === 'text' || this.isVoicePresence())
1434
+ return;
1435
+ this.media?.switchToTextOnly();
1436
+ this.mode = 'text';
1437
+ this.manualTurn = false;
1438
+ this.pushActive = false;
1439
+ this.send('session.mode', { mode: 'text' });
1440
+ this.send('media.state', {
1441
+ microphone: 'unavailable',
1442
+ playback: 'paused',
1443
+ captions: true,
1444
+ });
1445
+ const record = this.readResume();
1446
+ if (record) {
1447
+ this.storeResume({ ...record, mode: 'text', manualTurn: false });
1448
+ }
1449
+ this.view?.setMuted(false);
1450
+ this.view?.configureVoiceControls(false, false, false);
1451
+ this.view?.setStatus(this.view.message('textMode'));
1452
+ }
1453
+ async submitFeedback(outcome) {
1454
+ if (!this.result)
1455
+ return;
1456
+ try {
1457
+ await this.runtime.feedback(this.result, { rating: null, outcome });
1458
+ }
1459
+ catch {
1460
+ // Feedback is optional and never reopens a terminal session.
1461
+ }
1462
+ }
1463
+ retry() {
1464
+ const resume = this.readResume();
1465
+ if (resume && this.lock.acquire()) {
1466
+ this.view?.showConnecting(true, resume.mode);
1467
+ return this.resume(resume);
1468
+ }
1469
+ if (this.lastLaunch) {
1470
+ return this.launch(this.lastLaunch.mode, this.lastLaunch.scopes);
1471
+ }
1472
+ }
1473
+ scheduleReconnect() {
1474
+ if (this.ended ||
1475
+ this.reconnecting ||
1476
+ this.destroyed ||
1477
+ !navigator.onLine) {
1478
+ return;
1479
+ }
1480
+ const record = this.readResume();
1481
+ if (!record || this.reconnectAttempts >= 5) {
1482
+ this.view?.showError(this.view.message('reconnectFailed'), false);
1483
+ return;
1484
+ }
1485
+ this.reconnectAttempts += 1;
1486
+ const delay = Math.min(8000, 500 * 2 ** (this.reconnectAttempts - 1));
1487
+ this.view?.showConnecting(true, record.mode);
1488
+ this.cleanupTransport();
1489
+ setTimeout(() => {
1490
+ if (!this.ended && navigator.onLine)
1491
+ void this.resume(record);
1492
+ }, delay + Math.floor(Math.random() * 250));
1493
+ }
1494
+ createMedia() {
1495
+ return new RealtimeMedia(this.runtime, {
1496
+ onLocalStream: (stream) => {
1497
+ if (this.view instanceof VoicePresenceView) {
1498
+ this.view.setLocalStream(stream);
1499
+ }
1500
+ },
1501
+ onRemoteStream: (stream) => this.view?.setRemoteStream(stream),
1502
+ onConnectionState: (state) => {
1503
+ if (state === 'failed')
1504
+ this.scheduleReconnect();
1505
+ },
1506
+ onMicrophoneUnavailable: () => {
1507
+ if (this.isVoicePresence()) {
1508
+ this.send('experience.end', { reason: 'microphone_revoked' });
1509
+ this.finishFromServer('MICROPHONE_REVOKED');
1510
+ }
1511
+ else {
1512
+ void this.switchToText();
1513
+ }
1514
+ },
1515
+ });
1516
+ }
1517
+ finishFromServer(reasonCode) {
1518
+ if (this.ended)
1519
+ return;
1520
+ this.ended = true;
1521
+ this.clearResume();
1522
+ this.cleanupTransport();
1523
+ this.lock.release();
1524
+ this.view?.showEnded(reasonCode === 'VISITOR_INACTIVITY_LIMIT'
1525
+ ? this.view.message('idleEnded')
1526
+ : reasonCode === 'CREDIT_LIMIT_REACHED' ||
1527
+ reasonCode === 'CREDITS_UNAVAILABLE'
1528
+ ? this.view.message('limitReached')
1529
+ : undefined);
1530
+ this.state('ended');
1531
+ }
1532
+ cleanupTransport(options = {}) {
1533
+ if (!options.preserveConnectionRequest) {
1534
+ this.connectionAbort?.abort();
1535
+ this.connectionAbort = null;
1536
+ }
1537
+ this.presentation?.destroy();
1538
+ this.presentation = null;
1539
+ this.context?.stop();
1540
+ this.context = null;
1541
+ if (!options.preserveControlChannel) {
1542
+ this.channel?.close();
1543
+ this.channel = null;
1544
+ }
1545
+ this.media?.close();
1546
+ this.media = null;
1547
+ this.pendingAction = false;
1548
+ this.pendingConfirmation = null;
1549
+ this.pushActive = false;
1550
+ }
1551
+ async requestRuntimeEnd(result, fencingToken, fallbackChannel, terminalMessageId) {
1552
+ if (terminalMessageId &&
1553
+ fallbackChannel &&
1554
+ await fallbackChannel.waitForAck(terminalMessageId, 2000)) {
1555
+ return;
1556
+ }
1557
+ const controller = new AbortController();
1558
+ const timeout = setTimeout(() => controller.abort(), 2000);
1559
+ try {
1560
+ await this.runtime.end(result, fencingToken, controller.signal);
1561
+ }
1562
+ catch {
1563
+ try {
1564
+ fallbackChannel?.send('session.end', { reason: 'visitor' });
1565
+ }
1566
+ catch {
1567
+ // Runtime reconciliation finalizes sessions whose client disappeared.
1568
+ }
1569
+ }
1570
+ finally {
1571
+ clearTimeout(timeout);
1572
+ }
1573
+ }
1574
+ send(type, payload) {
1575
+ if (!this.channel?.isOpen)
1576
+ return null;
1577
+ try {
1578
+ return this.channel.send(type, payload);
1579
+ }
1580
+ catch {
1581
+ this.scheduleReconnect();
1582
+ return null;
1583
+ }
1584
+ }
1585
+ replayContext() {
1586
+ const meta = this.app.getSessionMeta();
1587
+ const sessionId = String(meta.sessionID ?? '');
1588
+ return /^\d{1,32}$/.test(sessionId)
1589
+ ? { replay: { sessionId, tabId: this.tabId } }
1590
+ : {};
1591
+ }
1592
+ visitorId() {
1593
+ const key = `__oxvo_ai_live_assist_visitor_${this.siteKey}`;
1594
+ const meta = this.app.getSessionMeta();
1595
+ const trackerId = typeof meta.userUUID === 'string' ? meta.userUUID : '';
1596
+ if (/^[A-Za-z0-9_.:-]{8,128}$/.test(trackerId))
1597
+ return trackerId;
1598
+ try {
1599
+ const stored = localStorage.getItem(key) ?? '';
1600
+ if (/^[A-Za-z0-9_.:-]{8,128}$/.test(stored))
1601
+ return stored;
1602
+ const created = randomId('visitor');
1603
+ localStorage.setItem(key, created);
1604
+ return created;
1605
+ }
1606
+ catch {
1607
+ return randomId('visitor');
1608
+ }
1609
+ }
1610
+ readResume() {
1611
+ try {
1612
+ return parseResumeRecord(sessionStorage.getItem(this.storageKey));
1613
+ }
1614
+ catch {
1615
+ return null;
1616
+ }
1617
+ }
1618
+ writeResume(result, mode, scopes, visualConsentGranted = scopes.includes('visual_context'), clientSequence = 0) {
1619
+ this.storeResume({
1620
+ sessionId: result.sessionId,
1621
+ resumeCredential: result.resumeCredential,
1622
+ tabId: this.tabId,
1623
+ clientSequence,
1624
+ mode,
1625
+ scopes: [...scopes],
1626
+ locale: this.locale,
1627
+ expiresAt: Date.now() + STORAGE_TTL_MS,
1628
+ visualConsentGranted,
1629
+ manualTurn: mode === 'voice' && this.manualTurn,
1630
+ protocolVersion: result.protocolVersion,
1631
+ experienceMode: result.experienceMode,
1632
+ fallbackIntent: this.activeFallback,
1633
+ });
1634
+ }
1635
+ updateResumeManualTurn() {
1636
+ const record = this.readResume();
1637
+ if (record)
1638
+ this.storeResume({ ...record, manualTurn: this.manualTurn });
1639
+ }
1640
+ updateResumeSequence(clientSequence) {
1641
+ const record = this.readResume();
1642
+ if (record && record.clientSequence !== clientSequence) {
1643
+ this.storeResume({ ...record, clientSequence });
1644
+ }
1645
+ }
1646
+ storeResume(record) {
1647
+ try {
1648
+ sessionStorage.setItem(this.storageKey, JSON.stringify(record));
1649
+ }
1650
+ catch {
1651
+ // Resume is an enhancement; active in-memory control remains valid.
1652
+ }
1653
+ }
1654
+ clearResume() {
1655
+ try {
1656
+ sessionStorage.removeItem(this.storageKey);
1657
+ }
1658
+ catch {
1659
+ // Ignore unavailable storage.
1660
+ }
1661
+ }
1662
+ publicError(error) {
1663
+ if (error instanceof RuntimeClientError)
1664
+ return error.message;
1665
+ if (error instanceof DOMException && error.name === 'NotAllowedError') {
1666
+ return this.isVoicePresence()
1667
+ ? 'Microphone access was not granted. Allow microphone access to use voice assist.'
1668
+ : 'Microphone access was not granted. You can continue with text.';
1669
+ }
1670
+ return 'AI Live Assist is temporarily unavailable.';
1671
+ }
1672
+ retryable(error) {
1673
+ return error instanceof RuntimeClientError ? error.retryable : false;
1674
+ }
1675
+ string(value) {
1676
+ return typeof value === 'string' ? value.slice(0, 2000) : '';
1677
+ }
1678
+ state(value) {
1679
+ try {
1680
+ this.options.onStateChange?.(value);
1681
+ }
1682
+ catch {
1683
+ // Consumer callbacks cannot interrupt safety state transitions.
1684
+ }
1685
+ }
1686
+ sendPresentationResult(value) {
1687
+ this.send('presentation.result', { ...value });
1688
+ }
1689
+ isVoicePresence() {
1690
+ return (this.bootstrap?.experienceMode ?? 'standard') === 'voice_presence';
1691
+ }
1692
+ lowPowerMode() {
1693
+ const connection = navigator.connection;
1694
+ const configured = this.bootstrap?.appearance?.voicePresence?.visual.lowPowerMode;
1695
+ if (configured === 'always')
1696
+ return true;
1697
+ if (configured === 'never')
1698
+ return false;
1699
+ return connection?.saveData === true || connection?.effectiveType === '2g';
1700
+ }
1701
+ bindWindowLifecycle() {
1702
+ window.addEventListener('pagehide', this.onPageHide);
1703
+ window.addEventListener('pageshow', this.onPageShow);
1704
+ window.addEventListener('online', this.onOnline);
1705
+ window.addEventListener('offline', this.onOffline);
1706
+ window.addEventListener('focus', this.onWindowFocus);
1707
+ document.addEventListener('visibilitychange', this.onVisibilityChange);
1708
+ document.addEventListener('pointerdown', this.onVisitorControl, true);
1709
+ document.addEventListener('keydown', this.onVisitorControl, true);
1710
+ }
1711
+ requestFocusedHandoff() {
1712
+ if (document.visibilityState === 'hidden' ||
1713
+ this.destroyed ||
1714
+ this.ended ||
1715
+ this.result ||
1716
+ this.reconnecting ||
1717
+ !this.bootstrap ||
1718
+ !this.view ||
1719
+ !this.isVoicePresence()) {
1720
+ return;
1721
+ }
1722
+ this.lock.requestTakeover();
1723
+ }
1724
+ handoffToFocusedTab(requesterId, requestId) {
1725
+ if (this.destroyed ||
1726
+ this.ended ||
1727
+ !this.isVoicePresence()) {
1728
+ return;
1729
+ }
1730
+ if (this.reconnecting) {
1731
+ this.pendingFocusedHandoff = { requesterId, requestId };
1732
+ return;
1733
+ }
1734
+ if (!this.result)
1735
+ return;
1736
+ this.pendingFocusedHandoff = null;
1737
+ const record = this.readResume();
1738
+ if (!record || !record.tabId)
1739
+ return;
1740
+ this.cleanupTransport();
1741
+ this.result = null;
1742
+ this.clearResume();
1743
+ if (!this.lock.handoff(requesterId, requestId, record)) {
1744
+ this.storeResume(record);
1745
+ if (this.lock.acquire()) {
1746
+ this.view?.showConnecting(true, record.mode);
1747
+ void this.resume(record);
1748
+ }
1749
+ }
1750
+ }
1751
+ async acceptFocusedTabHandoff(payload) {
1752
+ const record = parseResumeRecord(JSON.stringify(payload));
1753
+ if (!record ||
1754
+ !record.tabId ||
1755
+ record.protocolVersion !== 2 ||
1756
+ record.experienceMode !== 'voice_presence') {
1757
+ this.lock.release();
1758
+ return;
1759
+ }
1760
+ this.tabId = record.tabId;
1761
+ this.storeResume(record);
1762
+ this.ended = false;
1763
+ this.view?.showActiveElsewhere(false);
1764
+ if (!this.bootstrap || !this.view || this.reconnecting || this.destroyed)
1765
+ return;
1766
+ this.view.showConnecting(true, record.mode);
1767
+ await this.resume(record);
1768
+ }
1769
+ }