PageSourceSearch

https://www.meillandrichardier.com/static/version1790243132/fronte…/Webcooking_AiChatbot/js/hyva-chat.min.js

js meillandrichardier.com collected 2026-09-24 22:12:43 UTC 29,334 bytes, 719 lines download raw bytes

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.