1(function (global) { 2 'use strict'; 3 4 // Exposed globally for backwards compatibility: third-party overrides of 5 // hyva/chat.phtml may still use x-data="webcookingChatComponent()". 6 global.webcookingChatComponent = function () { 7 return { 8 composerHelpMobile: false, 9 composerResetToken: 0, 10 config: {}, 11 controller: null, 12 conversationActive: false, 13 conversationClosed: false, 14 input: '', 15 inputDisabled: false, 16 isListening: false, 17 isSending: false, 18 loading: false, 19 loadingMessages: false, 20 unavailable: false, 21 liveMessage: '', 22 messages: [], 23 outsideDismissHandler: null, 24 recognition: null, 25 translations: {}, 26 unsubscribe: null, 27 unsubscribeTouchPrimary: null, 28 visible: false, 29 voiceBaseInput: '', 30 voiceDiscardResults: false, 31 voiceEnabled: false, 32 voiceFinalTranscript: '', 33 voiceInterimTranscript: '', 34 voiceRestartAttempts: 0, 35 voiceRestartTimeoutId: null, 36 voiceSupported: false, 37 voiceWantsListening: false, 38 39 queueScrollToBottom() { 40 this.$nextTick(() => { 41 window.requestAnimationFrame(() => { 42 window.requestAnimationFrame(() => { 43 if (!this.$refs.messagesPanel) { 44 return; 45 } 46 47 this.$refs.messagesPanel.scrollTop = this.$refs.messagesPanel.scrollHeight; 48 }); 49 }); 50 }); 51 }, 52 53 buildMessagesScrollKey(messages) { 54 var list = Array.isArray(messages) ? messages : []; 55 var lastMessage = list.length ? list[list.length - 1] : null; 56 57 if (!lastMessage) { 58 return '0'; 59 } 60 61 return [ 62 list.length, 63 lastMessage.id || '', 64 lastMessage.role || '', 65 lastMessage.loading ? '1' : '0', 66 String(lastMessage.content || ''), 67 String(lastMessage.loading_phrase || '') 68 ].join('|'); 69 }, 70 71 syncFromState(state) { 72 this.visible = !!(state && state.ui && state.ui.visible); 73 this.loading = !!(state && state.ui && state.ui.loading); 74 this.loadingMessages = !!(state && state.ui && state.ui.loadingMessages); 75 this.isSending = !!(state && state.ui && state.ui.sending); 76 this.conversationClosed = !!(state && state.ui && state.ui.conversationClosed); 77 this.conversationActive = !!(state && state.ui && state.ui.conversationActive); 78 this.inputDisabled = !!(state && state.ui && state.ui.inputDisabled); 79 this.unavailable = !!(state && state.ui && state.ui.unavailable); 80 this.messages = Array.isArray(state && state.messages) ? state.messages : []; 81 this.config = Object.assign({}, (state && state.config) || this.config || {}); 82 }, 83 84 buildControllerOptions() { 85 var rawConfig = {}; 86 var rawTranslations = {}; 87 if (this.$el.dataset.config) { 88 try { 89 rawConfig = JSON.parse(this.$el.dataset.config); 90 } catch (error) { 91 rawConfig = {}; 92 } 93 } 94 if (this.$el.dataset.translations) { 95 try { 96 rawTranslations = JSON.parse(this.$el.dataset.translations); 97 } catch (error) { 98 rawTranslations = {}; 99 } 100 } 101 this.translations = Object.assign({}, rawTranslations); 102 return { 103 closeUrl: this.$el.dataset.closeUrl || '', 104 config: rawConfig, 105 isAdmin: false, 106 loadUrl: this.$el.dataset.loadUrl || '', 107 locationHref: window.location.href, 108 sendUrl: this.$el.dataset.sendUrl || '', 109 startUrl: this.$el.dataset.startUrl || '', 110 storagePrefix: 'webcooking_ai_chat_front', 111 toolStatusUrl: this.$el.dataset.toolStatusUrl || '', 112 toolStatusStreamUrl: this.$el.dataset.toolStatusStreamUrl || '', 113 translations: { 114 createTicketMessage: rawTranslations.createTicketMessage || this.$el.dataset.createTicketMessage || 'I would like to create a support ticket for my request.' 115 } 116 }; 117 }, 118 119 t(key, fallback) { 120 if (this.translations && typeof this.translations[key] === 'string' && this.translations[key] !== '') { 121 return this.translations[key]; 122 } 123 124 return fallback; 125 }, 126
127 async init() { 128 if (this.controller) { 129 return; 130 } 131 132 var core = global.WebcookingAiChatbot; 133 if (!core || typeof core.createChatController !== 'function') { 134 console.error('WebcookingAiChatbot core is not available'); 135 return; 136 } 137 138 this.controller = core.createChatController(this.buildControllerOptions()); 139 this.syncFromState(this.controller.getState()); 140 this.voiceEnabled = !!this.config.enable_voice_input; 141 142 // Mirror property rather than calling the helper straight from 143 // composerHelpText(): Alpine only re-evaluates on reactive reads, so the 144 // help line would not follow a rotation or an external keyboard otherwise. 145 var helpers = (core && core.helpers) || {}; 146 if (typeof helpers.isTouchPrimaryDevice === 'function') { 147 this.composerHelpMobile = helpers.isTouchPrimaryDevice(); 148 } 149 if (typeof helpers.onTouchPrimaryChange === 'function') { 150 this.unsubscribeTouchPrimary = helpers.onTouchPrimaryChange((matches) => { 151 this.composerHelpMobile = matches; 152 }); 153 } 154 155 this.unsubscribe = this.controller.subscribe((state, meta) => { 156 if (meta && meta.type === 'composer:reset') { 157 this.resetComposer(); 158 } 159 160 var wasVisible = this.visible; 161 var wasBusy = this.loading || this.loadingMessages || this.isSending; 162 var previousMessagesKey = this.buildMessagesScrollKey(this.messages); 163 this.syncFromState(state); 164 this.voiceEnabled = !!this.config.enable_voice_input; 165 var nextMessagesKey = this.buildMessagesScrollKey(this.messages); 166 167 if (wasVisible !== this.visible) { 168 this.liveMessage = this.visible 169 ? this.t('chatOpened', 'Chat opened') 170 : this.t('chatMinimized', 'Chat minimized'); 171 } else if (!wasBusy && (this.loading || this.loadingMessages || this.isSending)) { 172 this.liveMessage = this.t('composerHelpBusy', 'Preparing the answer...'); 173 } 174 175 if (!wasVisible && this.visible) { 176 this.$nextTick(() => { 177 if (this.$refs.inputArea) { 178 this.$refs.inputArea.focus(); 179 } 180 }); 181 } 182 183 if ((!wasVisible && this.visible) || (this.visible && previousMessagesKey !== nextMessagesKey)) { 184 this.queueScrollToBottom(); 185 } 186 }); 187 188 this.syncFromState(await this.controller.init()); 189 this.voiceEnabled = !!this.config.enable_voice_input; 190 if (this.visible || this.messages.length) { 191 this.queueScrollToBottom(); 192 } 193 this.initVoice(); 194 this.registerOutsideDismiss(); 195 }, 196 197 async toggle() { 198 if (!this.controller) { 199 return; 200 } 201 202 var wasVisible = this.visible;
203 await this.controller.toggle(); 204 205 if (wasVisible) { 206 this.stopVoice(); 207 this.$nextTick(() => { 208 if (this.$refs.toggleBtn) { 209 this.$refs.toggleBtn.focus(); 210 } 211 }); 212 } 213 }, 214 215 hide() { 216 if (!this.controller) { 217 return; 218 } 219 220 this.stopVoice(); 221 this.controller.hide(); 222 this.$nextTick(() => { 223 if (this.$refs.toggleBtn) { 224 this.$refs.toggleBtn.focus(); 225 } 226 }); 227 }, 228 229 registerOutsideDismiss() { 230 if (this.outsideDismissHandler) { 231 return; 232 } 233 234 // Desktop dismissal. Alpine's @click.outside ignores zero-size elements, 235 // and the widget root collapses to 0x0 on desktop (launcher and window are 236 // both position:fixed, hence out of flow) so the directive only ever fired 237 // on mobile. A manual listener keyed on DOM containment is independent of 238 // the root geometry. On mobile the tap lands on the backdrop overlay 239 // (inside the root), so this handler no-ops there and the overlay closes. 240 this.outsideDismissHandler = (event) => { 241 // Primary button only; keep the chat if it is already minimized. 242 if (event.button === 1 || event.button === 2 || !this.visible) { 243 return; 244 } 245 246 var target = event.target; 247 if (this.$el.contains(target)) { 248 return; 249 } 250 // The debug popup is appended to <body>, outside this widget. 251 if (target && target.closest && target.closest('#webcooking-ai-debug-overlay')) { 252 return; 253 } 254 // Operating the page (link, add to cart, quantityâ¦) while the window is 255 // configured to survive page changes: minimizing here would persist 256 // visible=0 and defeat the reopen. Only inert areas dismiss in that 257 // mode. Both helpers may be missing on an older cached chat-core: fall 258 // back to dismissing, the behaviour that shipped before. 259 var helpers = (global.WebcookingAiChatbot && global.WebcookingAiChatbot.helpers) || {}; 260 if (typeof helpers.isInteractiveTarget === 'function' 261 && helpers.isInteractiveTarget(target) 262 && typeof this.controller.keepsWindowOpenAcrossPages === 'function' 263 && this.controller.keepsWindowOpenAcrossPages()) { 264 return; 265 } 266 267 // Leave focus where the user pressed; unlike hide() we do not return 268 // focus to the launcher. 269 this.stopVoice(); 270 this.controller.hide(); 271 }; 272 // mousedown, not click: reading containment before any in-dialog handler 273 // re-renders avoids acting on a target that was detached by the re-render. 274 document.addEventListener('mousedown', this.outsideDismissHandler); 275 }, 276 277 destroy() { 278 if (this.outsideDismissHandler) { 279 document.removeEventListener('mousedown', this.outsideDismissHandler); 280 this.outsideDismissHandler = null; 281 } 282 283 if (typeof this.unsubscribe === 'function') { 284 this.unsubscribe(); 285 this.unsubscribe = null; 286 } 287 288 if (typeof this.unsubscribeTouchPrimary === 'function') { 289 this.unsubscribeTouchPrimary(); 290 this.unsubscribeTouchPrimary = null; 291 } 292 }, 293 294 handleComposerEnter(event) { 295 // Mid-composition Enter (Android suggestions, CJK IMEs) confirms a 296 // candidate: never send, and never swallow the key. 297 if (event && (event.isComposing || event.keyCode === 229)) { 298 return; 299 } 300 301 if (event && event.shiftKey) { 302 return; 303 } 304 305 // Read live rather than from composerHelpMobile: that mirror only exists to 306 // make the help copy reactive, and a missing helper must fall back to 307 // desktop behaviour rather than silently disable sending. 308 var helpers = (global.WebcookingAiChatbot && global.WebcookingAiChatbot.helpers) || {}; 309 if (typeof helpers.isTouchPrimaryDevice === 'function' && helpers.isTouchPrimaryDevice()) { 310 return; 311 } 312 313 if (event) { 314 event.preventDefault(); 315 } 316 317 if (this.loading || this.loadingMessages || this.isSending || this.inputDisabled) { 318 return; 319 } 320 321 this.send(); 322 }, 323 324 // Driven by the core's composer:reset event, so every path that ends the 325 // conversation clears the draft â including template overrides that wire the 326 // buttons themselves. 327 resetComposer() { 328 this.composerResetToken += 1; 329 // Raise the discard flag before stopping, so a result already queued by the 330 // engine cannot slip through (same order as chat.js). 331 this.voiceDiscardResults = true; 332 this.stopVoice(); 333 this.input = ''; 334 this.voiceBaseInput = ''; 335 this.voiceFinalTranscript = ''; 336 this.voiceInterimTranscript = ''; 337 }, 338 339 async send(extraPayload) { 340 if (!this.controller || this.loading || this.loadingMessages || this.isSending) { 341 return; 342 } 343 344 var payload = extraPayload || {}; 345 var forcedMessage = typeof payload.message === 'string' ? String(payload.message || '').trim() : ''; 346 var rawInput = String(this.input || ''); 347 var message = forcedMessage || rawInput.trim(); 348 349 if (!message) { 350 return; 351 } 352 353 if (!forcedMessage) { 354 this.input = ''; 355 } 356 357 // Token guard: a send can now fail because the user ended the conversation 358 // mid-request, and restoring the draft then would undo the reset we just 359 // performed â putting the "unsent message" straight back in the composer. 360 var token = this.composerResetToken; 361 var sent = await this.controller.sendMessage(message, payload); 362 if (!sent && !forcedMessage && token === this.composerResetToken) { 363 this.input = rawInput; 364 } 365 366 if (!forcedMessage) { 367 this.$nextTick(() => { 368 if (this.$refs.inputArea) { 369 this.$refs.inputArea.focus(); 370 } 371 }); 372 } 373 }, 374 375 async handleOptionClick(messageIndex, option) { 376 if (!this.controller) { 377 return; 378 } 379 380 await this.controller.selectOption(messageIndex, option); 381 }, 382 383 getUserOptions(userOptions) { 384 return global.WebcookingAiChatbot.getUserOptionsList(userOptions); 385 }, 386 387 async createSupportTicket() { 388 if (!this.controller) { 389 return; 390 } 391 392 await this.controller.createSupportTicket(); 393 }, 394 395 async closeConversation() { 396 if (!this.controller) { 397 return; 398 } 399 400 this.stopVoice(); 401 await this.controller.closeConversation(); 402 }, 403 404 resetChatbox() { 405 if (!this.controller) { 406 return; 407 } 408 409 this.stopVoice(); 410 this.controller.resetChatbox(); 411 }, 412 413 trackEscalationClick() { 414 if (!this.controller) { 415 return; 416 } 417 418 this.controller.trackEscalationClick(); 419 }, 420 421 assistantSanitizedHtml(value) { 422 return global.WebcookingAiChatbot.helpers.assistantSanitizedHtml(value); 423 }, 424 425 escapeHtml(value) { 426 return global.WebcookingAiChatbot.helpers.escapeHtml(value); 427 }, 428 429 openDebugPopup(debugData) { 430 var sessionId = this.controller && this.controller.getState && this.controller.getState().session 431 ? this.controller.getState().session.id 432 : null; 433 var enriched = sessionId ? Object.assign({ session_id: sessionId }, debugData) : debugData; 434 global.WebcookingAiChatbot.openDebugPopup(enriched); 435 }, 436 437 buildDebugPayload(metadata) { 438 return metadata && (metadata.debug || global.WebcookingAiChatbot.buildDebugPayload(metadata)); 439 }, 440 441 isIntroMessage(message, index) { 442 var greeting = this.config && (this.config.welcome_message || this.config.tagline); 443 444 if (!message || message.role !== 'assistant' || index !== 0 || message.loading) { 445 return false;
446 } 447 448 if (message.metadata && message.metadata.user_options) { 449 return true; 450 } 451 452 return !!greeting && String(message.content || '').trim() === String(greeting).trim(); 453 }, 454 455 getInitialPromptExamples() { 456 var options = global.WebcookingAiChatbot.getInitialUserOptionsFromConfig(this.config || {}); 457 var list = global.WebcookingAiChatbot.getUserOptionsList(options); 458 return list.slice(0, 3).map(function (option) { 459 return option.label; 460 }); 461 }, 462 463 composerPlaceholder() { 464 if (this.config && this.config.composer_placeholder) { 465 return String(this.config.composer_placeholder); 466 } 467 468 var examples = this.getInitialPromptExamples(); 469 if (examples.length) { 470 return this.t('examplePrefix', 'Example: ') + examples[0]; 471 } 472 473 return this.t('defaultComposerPlaceholder', 'Ask a question about your order, a product or support'); 474 }, 475 476 composerHelpText() { 477 if (this.loading || this.loadingMessages || this.isSending) { 478 return this.t('composerHelpBusy', 'Preparing the answer...'); 479 } 480 481 if (this.composerHelpMobile) { 482 return this.t('composerHelpIdleMobile', 'Tap Send to send your message. Enter adds a new line.'); 483 } 484 485 return this.t('composerHelpIdle', 'Press Enter to send, Shift+Enter for a new line.'); 486 }, 487 488 quickFill(message) { 489 this.input = String(message || ''); 490 this.$nextTick(() => { 491 if (this.$refs.inputArea) { 492 this.$refs.inputArea.focus(); 493 } 494 }); 495 }, 496 497 trapFocus(event) { 498 var focusableElements; 499 var firstElement; 500 var lastElement; 501 var selector = 'a[href], area[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; 502 503 if (!event || event.key !== 'Tab' || !this.visible || !this.$refs.windowPanel) { 504 return; 505 } 506 507 focusableElements = Array.prototype.filter.call( 508 this.$refs.windowPanel.querySelectorAll(selector), 509 function (element) { 510 return element.offsetParent !== null; 511 } 512 ); 513 514 if (!focusableElements.length) { 515 return; 516 } 517 518 firstElement = focusableElements[0]; 519 lastElement = focusableElements[focusableElements.length - 1]; 520 521 if (event.shiftKey && document.activeElement === firstElement) { 522 event.preventDefault(); 523 lastElement.focus(); 524 return; 525 } 526 527 if (!event.shiftKey && document.activeElement === lastElement) { 528 event.preventDefault(); 529 firstElement.focus(); 530 } 531 }, 532 533 initVoice() { 534 this.voiceEnabled = !!this.config.enable_voice_input; 535 if (!this.voiceEnabled) { 536 this.voiceSupported = false; 537 return; 538 } 539 540 var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 541 if (!SpeechRecognition) { 542 this.voiceSupported = false; 543 return; 544 } 545 546 this.voiceSupported = true; 547 548 try { 549 this.recognition = new SpeechRecognition(); 550 this.recognition.continuous = true; 551 this.recognition.interimResults = true; 552 this.recognition.maxAlternatives = 1; 553 this.recognition.lang = document.documentElement.lang || 'fr-FR'; 554 555 this.recognition.onstart = () => { 556 this.isListening = true; 557 }; 558 559 this.recognition.onresult = (event) => { 560 // recognition.stop() is async: a final result still lands after it. 561 // Guarding on voiceWantsListening would therefore drop the last 562 // chunk of every manually stopped dictation â this flag is only 563 // raised when the conversation was reset under us. 564 if (this.voiceDiscardResults) { 565 return; 566 } 567 568 var interim = ''; 569 570 for (var index = event.resultIndex; index < event.results.length; index += 1) { 571 var result = event.results[index]; 572 var transcript = result && result[0] && result[0].transcript ? String(result[0].transcript) : ''; 573 if (!transcript) { 574 continue; 575 } 576 577 if (result.isFinal) { 578 this.voiceFinalTranscript = String((this.voiceFinalTranscript + ' ' + transcript) || '').trim(); 579 } else { 580 interim += transcript; 581 } 582 } 583 584 this.voiceInterimTranscript = String(interim || '').trim(); 585 this.input = [this.voiceBaseInput, this.voiceFinalTranscript, this.voiceInterimTranscript].filter(
585Boolean).join(' '); 586 587 this.$nextTick(() => { 588 if (this.$refs.inputArea) { 589 this.$refs.inputArea.value = this.input; 590 } 591 }); 592 }; 593 594 this.recognition.onerror = (event) => { 595 if (event && event.error === 'no-speech' && this.voiceWantsListening) { 596 this.scheduleVoiceRestart(); 597 return; 598 } 599 this.stopVoice(); 600 }; 601 602 this.recognition.onend = () => { 603 if (this.voiceWantsListening) { 604 this.isListening = true; 605 this.scheduleVoiceRestart(); 606 return; 607 } 608 609 this.isListening = false; 610 }; 611 } catch (error) { 612 this.voiceSupported = false; 613 this.recognition = null; 614 } 615 }, 616 617 scheduleVoiceRestart() { 618 if (!this.voiceWantsListening || !this.recognition || this.voiceRestartTimeoutId) { 619 return; 620 } 621 622 this.voiceRestartAttempts += 1; 623 if (this.voiceRestartAttempts > 6) { 624 this.stopVoice(); 625 return; 626 } 627 628 this.voiceRestartTimeoutId = window.setTimeout(() => { 629 this.voiceRestartTimeoutId = null; 630 if (!this.voiceWantsListening || !this.recognition) { 631 return; 632 } 633 634 try { 635 this.recognition.start(); 636 } catch (error) { 637 this.scheduleVoiceRestart(); 638 } 639 }, Math.min(2000, 300 + (this.voiceRestartAttempts * 200))); 640 }, 641 642 async toggleVoice() { 643 if (!this.voiceSupported || !this.recognition) { 644 return; 645 } 646 647 if (this.voiceWantsListening) { 648 this.stopVoice(); 649 return; 650 } 651 652 var token = this.composerResetToken; 653 654 try { 655 if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { 656 var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 657 stream.getTracks().forEach((track) => track.stop()); 658 } 659 } catch (error) { 660 this.stopVoice(); 661 return; 662 } 663 664 // Ending the conversation while the permission prompt was up must not 665 // start the microphone on the fresh one. 666 if (token !== this.composerResetToken) { 667 return; 668 } 669 670 this.voiceDiscardResults = false; 671 this.voiceBaseInput = String(this.input || '').trim(); 672 this.voiceFinalTranscript = ''; 673 this.voiceInterimTranscript = ''; 674 this.voiceWantsListening = true; 675 this.voiceRestartAttempts = 0; 676 this.isListening = true; 677 678 try { 679 this.recognition.start(); 680 } catch (error) { 681 this.stopVoice(); 682 } 683 }, 684 685 stopVoice() { 686 this.voiceWantsListening = false; 687 this.voiceRestartAttempts = 0; 688 this.isListening = false; 689 690 if (this.voiceRestartTimeoutId) { 691 window.clearTimeout(this.voiceRestartTimeoutId); 692 this.voiceRestartTimeoutId = null; 693 } 694 695 if (this.recognition) { 696 try { 697 this.recognition.stop(); 698 } catch (error) { } 699 } 700 } 701 }; 702 }; 703 704 function registerWebcookingChat() { 705 if (!global.Alpine || typeof global.Alpine.data !== 'function') { 706 return; 707 } 708 709 global.Alpine.data('webcookingChat', global.webcookingChatComponent); 710 } 711 712 if (global.Alpine && typeof global.Alpine.data === 'function') { 713 // Alpine already loaded (e.g. this script was injected after Alpine 714 // by a JS optimizer); alpine:init has already fired. 715 registerWebcookingChat(); 716 } else { 717 document.addEventListener('alpine:init', registerWebcookingChat, { once: true }); 718 } 719})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.