PageSourceSearch

https://www.promessedefleurs.es/static/version1790243132/frontend/…/Webcooking_AiChatbot/js/chat-core.min.js

js promessedefleurs.es collected 2026-09-25 09:49:58 UTC 71,975 bytes, 1,913 lines download raw bytes

1(function (global) {
2    'use strict';
3
4    var namespace = global.WebcookingAiChatbot || {};
5
6    function safeClone(value) {
7        if (value === undefined) {
8            return undefined;
9        }
10
11        try {
12            return JSON.parse(JSON.stringify(value));
13        } catch (error) {
14            return value;
15        }
16    }
17
18    function createMessageId() {
19        return String(Date.now()) + '-' + Math.random().toString(36).slice(2, 11);
20    }
21
22    function escapeHtml(value) {
23        var div = document.createElement('div');
24        div.textContent = value === undefined || value === null ? '' : String(value);
25        return div.innerHTML;
26    }
27
28    function decodeHtml(value) {
29        var textarea = document.createElement('textarea');
30        textarea.innerHTML = String(value || '');
31        return textarea.value;
32    }
33
34    function isAllowedHrefUrl(url) {
35        if (typeof url !== 'string') {
36            return false;
37        }
38
39        var decoded = decodeHtml(url).trim();
40        if (!decoded) {
41            return false;
42        }
43
44        if (/^\s*(javascript|data|vbscript)\s*:/i.test(decoded)) {
45            return false;
46        }
47
48        if (/^\/\//.test(decoded)) {
49            return false;
50        }
51
52        if (decoded.charAt(0) === '/') {
53            return !/(?:javascript|data|vbscript):/i.test(decoded);
54        }
55
56        return /^https?:\/\//i.test(decoded);
57    }
58
59    function sanitizeAssistantHtml(inputHtml) {
60        var html = inputHtml === undefined || inputHtml === null ? '' : String(inputHtml);
61        var allowedTags = {
62            A: true,
63            B: true,
64            BR: true,
65            EM: true,
66            I: true,
67            LI: true,
68            OL: true,
69            P: true,
70            STRONG: true,
71            UL: true
72        };
73
74        var template = document.createElement('template');
75        template.innerHTML = html;
76
77        function walk(node) {
78            var children = Array.prototype.slice.call(node.childNodes || []);
79
80            children.forEach(function (child) {
81                if (child.nodeType === Node.ELEMENT_NODE) {
82                    var element = child;
83                    var tag = String(element.tagName || '').toUpperCase();
84
85                    if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'IFRAME' || tag === 'OBJECT' || tag === 'EMBED' || tag === 'LINK' || tag === 'META') {
86                        element.remove();
87                        return;
88                    }
89
90                    if (!allowedTags[tag]) {
91                        while (element.firstChild) {
92                            if (element.parentNode) {
93                                element.parentNode.insertBefore(element.firstChild, element);
94                            }
95                        }
96                        element.remove();
97                        return;
98                    }
99
100                    Array.prototype.slice.call(element.attributes || []).forEach(function (attribute) {
101                        var name = String(attribute.name || '').toLowerCase();
102
103                        if (name.indexOf('on') === 0) {
104                            element.removeAttribute(attribute.name);
105                            return;
106                        }
107
108                        if (tag === 'A') {
109                            if (name === 'href') {
110                                if (!isAllowedHrefUrl(String(attribute.value || ''))) {
111                                    while (element.firstChild) {
112                                        if (element.parentNode) {
113                                            element.parentNode.insertBefore(element.firstChild, element);
114                                        }
115                                    }
116                                    element.remove();
117                                    return;
118                                }
119
120                                element.setAttribute('href', String(attribute.value || ''));
121                                return;
122                            }
123
124                            if (name === 'title') {
125                                return;
126                            }
127
128                            if (name === 'target') {
129                                if (String(attribute.value || '').toLowerCase() === '_blank') {
130                                    element.setAttribute('target', '_blank');
131                                    element.setAttribute('rel', 'noopener noreferrer');
132                                } else {
133                                    element.removeAttribute('target');
134                                }
135                                return;
136                            }
137
138                            if (name === 'rel') {
139                                element.removeAttribute('rel');
140                                return;
141                            }
142                        }
143
144                        if (tag !== 'A' || (name !== 'href' && name !== 'title' && name !== 'target' && name !== 'rel')) {
145                            element.removeAttribute(attribute.name);
146                        }
147                    });
148
149                    walk(element);
150                    return;
151                }
152
153                if (child.nodeType === Node.COMMENT_NODE) {
154                    child.remove();
155                }
156            });
157        }
158
159        walk(template.content);
160        return template.innerHTML;
161    }
162
163    function assistantSanitizedHtml(value) {
164        var text = value === undefined || value === null ? '' : String(value);
165        text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
166        text = text.replace(/\n{3,}/g, '\n\n');
167        text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
168        text = text.replace(/(^|[^*])\*(?!\*)(.+?)\*(?!\*)/g, '$1<em>$2</em>');
169        text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_match, label, rawUrl) {
170            var decodedUrl = decodeHtml(rawUrl).trim();
171            if (!isAllowedHrefUrl(decodedUrl)) {
172                return escapeHtml(label);
173            }
174
175            return '<a href="' + escapeHtml(decodedUrl) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(label) + '</a>';
176        });
177        text = text.replace(/\n/g, '<br>');
178        text = text.replace(/<br\s*\/?>\s*(?=<\/?(?:li|ul|ol)\b[^>]*>)/gi, '');
179        text = text.replace(/(<(?:ul|ol)\b[^>]*>)\s*<br\s*\/?>/gi, '$1');
180        return sanitizeAssistantHtml(text);
181    }
182
183    function buildDebugPayload(metadata) {
184        var keys = [
185            'confidence',
186            'intention',
187            'difficulties_to_answer',
188            'resolution_score',
189            'contact_satisfaction_score',
190            'should_ask_closure_confirmation',
191            'closure_confirmation_question',
192            'ready_to_close'
193        ];
194        var result = {};
195        var hasData = false;
196
197        keys.forEach(function (key) {
198            if (metadata && metadata[key] !== undefined && metadata[key] !== null) {
199                result[key] = metadata[key];
200                hasData = true;
201            }
202        });
203
204        return hasData ? result : null;
205    }
206
207    function formatMs(ms) {
208        if (ms === null || ms === undefined) return '—';
209        return (ms / 1000).toFixed(2) + 's';
210    }
211
212    function buildResponseTimeHtml(rt) {
213        if (!rt || rt.count === 0) {
214            return '<div class="webcooking-ai-debug-section">' +
215                '<div class="webcooking-ai-debug-section-title">Response Time</div>' +
216                '<div class="webcooking-ai-debug-stats">No data</div>' +
217                '</div>';
218        }
219        return '<div class="webcooking-ai-debug-section">' +
220            '<div class="webcooking-ai-debug-section-title">Response Time (' + rt.count + ' calls)</div>' +
221            '<div class="webcooking-ai-debug-stats">' +
222            '<span><strong>Avg:</strong> ' + formatMs(rt.avg_ms) + '</span>' +
223            '<span><strong>Min:</strong> ' + formatMs(rt.min_ms) + '</span>' +
224            '<span><strong>Max:</strong> ' + formatMs(rt.max_ms) + '</span>' +
225            '<span><strong>Last:</strong> ' + formatMs(rt.last_ms) + '</span>' +
226            '</div></div>';
227    }
228
229    function formatToolPayload(value) {
230        if (value === null || value === undefined) {
231            return '';
232        }
233        if (typeof value === 'string') {
234            try {
235                var parsed = JSON.parse(value);
236                return JSON.stringify(parsed, null, 2);
237            } catch (e) {
238                return value;
239            }
240        }
241        try {
242            return JSON.stringify(value, null, 2);
243        } catch (e) {
244            return String(value);
245        }
246    }
247
248    function buildToolCallsHtml(toolCalls) {
249        if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
250            return '';
251        }
252        var rows = toolCalls.map(function (call, index) {
253            var name = escapeHtml(String(call && call.name ? call.name : 'unknown'));
254            var duration = (call && call.duration_ms !== null && call.duration_ms !== undefined)
255                ? formatMs(call.duration_ms)
256                : '—';
257            var argsBody = escapeHtml(formatToolPayload(call ? call.arguments : null));
258            var responseBody = escapeHtml(formatToolPayload(call ? call.response : null));
259            return '<details class="webcooking-ai-tool-call"' + (index === 0 ? ' open' : '') + '>' +
260                '<summary><strong>' + name + '</strong> <span class="webcooking-ai-tool-call-duration">' + duration + '</span></summary>' +
261                '<div class="webcooking-ai-tool-call-body">' +
262                '<div class="webcooking-ai-tool-call-label">Arguments</div>' +
263                '<pre class="webcooking-ai-tool-call-pre">' + argsBody + '</pre>' +
264                '<div class="webcooking-ai-tool-call-label">Response</div>' +
265                '<pre class="webcooking-ai-tool-call-pre">' + responseBody + '</pre>' +
266                '</div></details>';
267        }).join('');
268        return '<div class="webcooking-ai-debug-section">' +
269            '<div class="webcooking-ai-debug-section-title">Tool Calls (' + toolCalls.length + ')</div>' +
270            '<div class="webcooking-ai-tool-calls-list">' + rows + '</div>' +
271            '</div>';
272    }
273
274    function openDebugPopup(debugData) {
275        var existing = document.getElementById('webcooking-ai-debug-overlay');
276        if (existing) {
277            existing.remove();
278        }
279
280        var overlay = document.createElement('div');
281        overlay.id = 'webcooking-ai-debug-overlay';
282        overlay.className = 'webcooking-ai-debug-overlay';
283
284        var popup = document.createElement('div');
285        popup.className = 'webcooking-ai-debug-popup';
286
287        var closeButton = document.createElement('button');
288        closeButton.type = 'button';
289        closeButton.innerHTML = '&times;';
290        closeButton.className = 'webcooking-ai-debug-close';
291        closeButton.addEventListener('click', function () {
292            overlay.remove();
293        });
294
295        var title = document.createElement('div');
296        title.className = 'webcooking-ai-debug-title';
297        title.textContent = 'Debug Info';
298
299        var responseTimeData = debugData.response_time || null;
300        var toolCallsData = Array.isArray(debugData.tool_calls) ? debugData.tool_calls : null;
301        var dataForJson = Object.assign({}, debugData);
302        delete dataForJson.response_time;
303        delete dataForJson.tool_calls;
304
305        var content = document.createElement('div');
306        content.className = 'webcooking-ai-debug-content-wrapper';
307        content.innerHTML = buildResponseTimeHtml(responseTimeData) + buildToolCallsHtml(toolCallsData);
308
309        var pre = document.createElement('pre');
310        pre.className = 'webcooking-ai-debug-content';
311        pre.textContent = JSON.stringify(dataForJson, null, 2);
312        content.appendChild(pre);
313
314        popup.appendChild(closeButton);
315        popup.appendChild(title);
316        popup.appendChild(content);
317        overlay.appendChild(popup);
318        overlay.addEventListener('click', function (event) {
319            if (event.target === overlay) {
320                overlay.remove();
321            }
322        });
323
324        document.body.appendChild(overlay);
325    }
326
327    function buildOptionId(label, index, usedIds) {
328        var slug = String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
329        if (slug.length > 40) {
330            slug = slug.slice(0, 40).replace(/^_+|_+$/g, '');
331        }
332        if (slug.length < 2) {
333            slug = 'option_' + index;
334        }
335        var candidate = slug;
336        var suffix = 2;
337        while (usedIds[candidate]) {
338            candidate = slug.slice(0, Math.max(2, 38 - String(suffix).length)) + '_' + suffix;
339            suffix += 1;
340        }
341        return candidate;
342    }
343
344    function getInitialUserOptionsFromConfig(config) {
345        var raw = config ? config.initial_options : null;
346        if (!raw) {
347            return null;
348        }
349
350        if (typeof raw === 'string') {
351            try {
352                raw = JSON.parse(raw);
353            } catch (error) {
354                return null;
355            }
356        }
357
358        var options = [];
359        var usedIds = {};
360
361        function addRow(row) {
362            if (!row || typeof row !== 'object') {
363                return;
364            }
365
366            var buttonText = String(row.button_text || '').trim();
367            var message = String(row.message || row.message_to_send || '').trim();
368            var preAnswer = String(row.pre_answer || '').trim();
369
370            if (!buttonText || !message) {
371                return;
372            }
373
374            var option = {
375                id: buildOptionId(buttonText, options.length, usedIds),
376                label: buttonText,
377                message: message
378            };
379            if (preAnswer) {
380                option.pre_answer = preAnswer;
381            }
382            options.push(option);
383            usedIds[option.id] = true;
384        }
385
386        if (Array.isArray(raw)) {
387            raw.forEach(addRow);
388        } else if (raw && typeof raw === 'object') {
389            Object.keys(raw).forEach(function (key) {
390                addRow(raw[key]);
391            });
392        }
393
394        return options.length ? { options: options } : null;
395    }
396
397    function getUserOptionsList(userOptions) {
398        if (!userOptions || typeof userOptions !== 'object' || !Array.isArray(userOptions.options)) {
399            return [];
400        }
401        return userOptions.options.filter(function (option) {
402            return option && typeof option === 'object' && typeof option.message === 'string' && option.message;
403        });
404    }
405
406    // Storefront mobile layout breakpoint (chat.css / _module.less); the admin widget
407    // has no such breakpoint and just reuses it as a phone/desktop proxy.
408    var MOBILE_MAX_WIDTH = 767;
409
410    function isMobileViewport() {
411        try {
412            if (typeof global.matchMedia === 'function') {
413                return !!global.matchMedia('(max-width: ' + MOBILE_MAX_WIDTH + 'px)').matches;
414            }
415        } catch (error) { }
416
417        var documentElement = global.document ? global.document.documentElement : null;
418        var width = global.innerWidth || (documentElement ? documentElement.clientWidth : 0) || 0;
419
420        return width > 0 && width <= MOBILE_MAX_WIDTH;
421    }
422
423    // A control the visitor is operating on the page: following a link, adding to cart,
424    // changing a quantity, opening a tab. Scoped to native controls and ARIA widget roles
425    // — a widget built from bare <div>s (Magento swatches) is not detectable this way and
426    // still counts as a plain outside click.
427    var INTERACTIVE_TARGET_SELECTOR = [
428        'a[href]',
429        'area[href]',
430        'button',
431        'input',
432        'select',
433        'textarea',
434        'label',
435        'summary',
436        '[contenteditable]:not([contenteditable="false"])',
437        '[onclick]',
438        '[role="button"]',
439        '[role="checkbox"]',
440        '[role="combobox"]',
441        '[role="link"]',
442        '[role="menuitem"]',
443        '[role="menuitemcheckbox"]',
444        '[role="menuitemradio"]',
445        // Magento's header search suggestions are <li role="option">, not links.
446        '[role="option"]',
447        '[role="radio"]',
448        '[role="searchbox"]',
449        '[role="slider"]',
450        // Custom quantity steppers and price sliders that are not native inputs.
451        '[role="spinbutton"]',
452        '[role="switch"]',
453        '[role="tab"]',
454        '[role="textbox"]'
455    ].join(',');
456
457    function isInteractiveTarget(target) {
458        return !!(target
459            && typeof target.closest === 'function'
460            && target.closest(INTERACTIVE_TARGET_SELECTOR));
461    }
462
463    // Distinct from isMobileViewport(), which answers "how much room is there" (layout).
464    // This one answers "what does the visitor type with": a touch keyboard has no
465    // Shift+Enter, so Enter must insert a newline instead of sending. Width would be the
466    // wrong signal — a landscape phone is ~900px, a narrow desktop window is not a phone.
467    var TOUCH_PRIMARY_MEDIA_QUERY = '(pointer: coarse) and (hover: none)';
468    // null = not resolved yet, false = matchMedia unavailable.
469    var touchPrimaryQuery = null;
470    var touchPrimaryListeners = [];
471
472    function getTouchPrimaryQuery() {
473        if (touchPrimaryQuery !== null) {
474            return touchPrimaryQuery;
475        }
476
477        if (typeof global.matchMedia !== 'function') {
478            touchPrimaryQuery = false;
479            return touchPrimaryQuery;
480        }
481
482        try {
483            touchPrimaryQuery = global.matchMedia(TOUCH_PRIMARY_MEDIA_QUERY);
484        } catch (error) {
485            touchPrimaryQuery = false;
486            return touchPrimaryQuery;
487        }
488
489        var notify = function (event) {
490            var matches = event && typeof event.matches === 'boolean'
491                ? event.matches
492                : isTouchPrimaryDevice();
493
494            // Iterate a copy so subscribing or unsubscribing from inside a callback stays
495            // safe whatever the removal strategy becomes.
496            touchPrimaryListeners.slice().forEach(function (listener) {
497                try {
498                    listener(matches);
499                } catch (listenerError) {
500                    // One view throwing must not stop the others from being notified.
501                }
502            });
503        };
504
505        if (typeof touchPrimaryQuery.addEventListener === 'function') {
506            touchPrimaryQuery.addEventListener('change', notify);
507        } else if (typeof touchPrimaryQuery.addListener === 'function') {
508            touchPrimaryQuery.addListener(notify);
509        }
510
511        return touchPrimaryQuery;
512    }
513
514    function isTouchPrimaryDevice() {
515        var query = getTouchPrimaryQuery();
516
517        return !!(query && query.matches);
518    }
519
520    // Always returns an unsubscribe function, including when matchMedia is missing, so
521    // callers can store it and call it unconditionally on teardown.
522    function onTouchPrimaryChange(listener) {
523        if (typeof listener !== 'function') {
524            return function () { };
525        }
526
527        getTouchPrimaryQuery();
528        touchPrimaryListeners.push(listener);
529
530        return function unsubscribeTouchPrimary() {
531            touchPrimaryListeners = touchPrimaryListeners.filter(function (item) {
532                return item !== listener;
533            });
534        };
535    }
536
537    function createChatController(options) {
538        var settings = options || {};
539        var translations = Object.assign({
540            createTicketMessage: 'I would like to create a support ticket for my request.'
541        }, settings.translations || {});
542
543        var subscribers = [];
544        var loadingPhraseTimerId = null;
545        var toolPollerTimerId = null;
546        var toolPollerStopped = true;
547        var lastToolMessage = null;
548        var toolEventSource = null;
549        // SSE failure is sticky for the session — fallback polling stays in effect
550        // until a new session is started (see setSessionIdentifiers).
551        var toolStreamFailures = 0;
552        var pendingAssistantPollerTimerId = null;
553
554        // Incremented when the user abandons the conversation (end / reset). Every async
555        // flow captures it before its first await and bails afterwards if it changed, so a
556        // late response can never resurrect session ids, messages or ui flags belonging to
557        // a conversation the user already ended.
558        //
559        // INVARIANT: the only two bump sites (closeConversation, resetChatbox) also reset
560        // ui.loading / ui.loadingMessages / ui.sending and call stopLoadingIndicators() +
561        // stopPendingAssistantPoller(), synchronously in the same tick as the bump and
562        // before any await. That is what makes it safe for a guarded `finally` to skip its
563        // whole body. Never bump inside clearSessionIdentifiers(): loadSessionMessages'
564        // catch calls it, and its own finally would then skip ui.loadingMessages = false
565        // and wedge the widget.
566        var conversationEpoch = 0;
567
568        function invalidateInFlightRequests() {
569            conversationEpoch += 1;
570        }
571
572        function isStaleEpoch(capturedEpoch) {
573            return capturedEpoch !== conversationEpoch;
574        }
575
576        // Separate from conversationEpoch: toolPollerStopped / toolPollerTimerId /
577        // lastToolMessage are shared by the whole controller and get re-armed by any
578        // subsequent send, so an epoch check would miss a stale tick landing inside the
579        // same conversation. Bumped by closeToolTransport(), which both starters call.
580        var toolTransportGeneration = 0;
581
582        function isStaleToolTransport(capturedGeneration) {
583            return capturedGeneration !== toolTransportGeneration;
584        }
585
586        var state = {
587            config: Object.assign({}, settings.config || {}),
588            endpoints: {
589                closeUrl: settings.closeUrl || '',
590                loadUrl: settings.loadUrl || '',
591                sendUrl: settings.sendUrl || '',
592                startUrl: settings.startUrl || '',
593                toolStatusUrl: settings.toolStatusUrl || '',
594                toolStatusStreamUrl: settings.toolStatusStreamUrl || ''
595            },
596            session: {
597                hasPersistedSession: false,
598                hydrated: false,
599                id: null,
600                storagePrefix: settings.storagePrefix || 'webcooking_ai_chat_front',
601                token: null
602            },
603            ui: {
604                conversationActive: false,
605                conversationClosed: false,
606                inputDisabled: false,
607                lastError: null,
608                loading: false,
609                loadingMessages: false,
610                sending: false,
611                unavailable: false,
612                visible: false
613            },
614            messages: [],
615            voice: {
616                enabled: !!((settings.config || {}).enable_voice_input),
617                supported: false,
618                listening: false
619            },
620            diagnostics: {
621                lastResponseMeta: null,
622                lastTransportError: null
623            }
624        };
625
626        function getStorageKey(suffix) {
627            return state.session.storagePrefix + '_' + suffix;
628        }
629
630        function storageGet(key, fallbackValue) {
631            try {
632                var value = global.localStorage.getItem(String(key));
633                return value !== null ? value : fallbackValue;
634            } catch (error) {
635                return fallbackValue;
636            }
637        }
638
639        function storageSet(key, value) {
640            try {
641                global.localStorage.setItem(String(key), String(value));
642            } catch (error) { }
643        }
644
645        function storageRemove(key) {
646            try {
647                global.localStorage.removeItem(String(key));
648            } catch (error) { }
649        }
650
651        function withFormKey(url) {
652            if (!settings.isAdmin || !settings.formKey) {
653                return url;
654            }
655
656            var separator = url.indexOf('?') === -1 ? '?' : '&';
657            return url + separator + 'form_key=' + encodeURIComponent(settings.formKey);
658        }
659
660        function publicState() {
661            return safeClone(state);
662        }
663
664        function notify(type, payload) {
665            var snapshot = publicState();
666            subscribers.forEach(function (subscriber) {
667                subscriber(snapshot, {
668                    payload: payload || null,
669                    type: type
670                });
671            });
672        }
673
674        function subscribe(callback) {
675            subscribers.push(callback);
676            return function unsubscribe() {
677                subscribers = subscribers.filter(function (subscriber) {
678                    return subscriber !== callback;
679                });
680            };
681        }
682
683        function syncConversationFlags() {
684            state.ui.conversationClosed = state.messages.some(function (message) {
685                return !!(message && message.role === 'assistant' && message.metadata && message.metadata.ready_to_close);
686            });
687            // Drives the launcher presence dot: live only once the visitor has actually written
688            // something (the welcome message alone does not count) and until the thread is
689            // closed, where the composer is gone and nothing more can be said.
690            state.ui.conversationActive = !state.ui.conversationClosed && state.messages.some(function (message) {
691                return !!(message && message.role === 'user');
692            });
693        }
694
695        function setVisible(visible) {
696            state.ui.visible = !!visible;
697            storageSet(getStorageKey('visible'), visible ? '1' : '0');
698        }
699
700        // Whether the merchant asked the window to survive a page change on the current
701        // viewport: drives both the restore on load and the outside-click dismissal, which
702        // must not minimize while the visitor is operating the page. An absent key (markup
703        // cached before the option existed) keeps the historical always-reopen behaviour.
704        function keepsWindowOpenAcrossPages() {
705            var flag = state.config[isMobileViewport() ? 'keep_open_mobile' : 'keep_open_desktop'];
706
707            if (flag === undefined || flag === null) {
708                return true;
709            }
710
711            return flag !== false && flag !== 0 && flag !== '0' && flag !== '';
712        }
713
714        function setSessionIdentifiers(nextSessionId, nextSessionToken) {
715            if (nextSessionId !== undefined) {
716                var previousSessionId = state.session.id;
717                if (nextSessionId === null || nextSessionId === '') {
718                    state.session.id = null;
719                    storageRemove(getStorageKey('session_id'));
720                } else {
721                    state.session.id = String(nextSessionId);
722                    storageSet(getStorageKey('session_id'), state.session.id);
723                }
724                if (state.session.id !== previousSessionId) {
725                    toolStreamFailures = 0;
726                    // A live poller owns ui.loading; killing it silently would strand that
727                    // flag and every later startSession/sendMessage bails on it. Guarded so
728                    // a normal send, which has no poller running, is untouched.
729                    if (pendingAssistantPollerTimerId !== null) {
730                        stopLoadingIndicators();
731                        state.ui.loading = false;
732                    }
733                    stopPendingAssistantPoller();
734                }
735            }
736
737            if (nextSessionToken !== undefined) {
738                if (nextSessionToken === null || nextSessionToken === '') {
739                    state.session.token = null;
740                    storageRemove(getStorageKey('session_token'));
741                } else {
742                    state.session.token = String(nextSessionToken);
743                    storageSet(getStorageKey('session_token'), state.session.token);
744                }
745            }
746
747            state.session.hasPersistedSession = !!(state.session.id && state.session.token);
748        }
749
750        function clearSessionIdentifiers() {
751            setSessionIdentifiers(null, null);
752        }
753
754        function normalizeMetadata(metadata) {
755            if (!metadata) {
756                return {};
757            }
758
759            if (Array.isArray(metadata)) {
760                try {
761                    return Object.fromEntries(metadata);
762                } catch (error) {
763                    return {};
764                }
765            }
766
767            if (typeof metadata === 'object') {
768                return Object.assign({}, metadata);
769            }
770
771            return {};
772        }
773
774        function normalizeMessage(message) {
775            if (!message || (message.role !== 'user' && message.role !== 'assistant')) {
776                return null;
777            }
778
779            if (message.tool_calls) {
780                return null;
781            }
782
783            return {
784                content: String(message.content || ''),
785                id: String(message.id || createMessageId()),
786                loading: !!message.loading,
787                loading_phrase: String(message.loading_phrase || ''),
788                metadata: normalizeMetadata(message.metadata),
789                role: message.role
790            };
791        }
792
793        function setMessages(messages) {
794            stopLoadingPhraseRotation();
795            state.messages = (messages || []).map(normalizeMessage).filter(Boolean);
796            syncConversationFlags();
797        }
798
799        function pushMessage(message) {
800            var normalized = normalizeMessage(message);
801            if (!normalized) {
802                return null;
803            }
804
805            state.messages.push(normalized);
806            syncConversationFlags();
807            return normalized;
808        }
809
810        function replaceMessageAt(index, message) {
811            var normalized = normalizeMessage(message);
812            if (!normalized) {
813                return;
814            }
815
816            state.messages.splice(index, 1, normalized);
817            syncConversationFlags();
818        }
819
820        function removeUserOptionsAt(index) {
821            var message = state.messages[index];
822            if (!message || !message.metadata || !message.metadata.user_options) {
823                return;
824            }
825
826            var metadata = Object.assign({}, message.metadata);
827            delete metadata.user_options;
828            replaceMessageAt(index, Object.assign({}, message, { metadata: metadata }));
829        }
830
831        function applySessionFromResponse(payload) {
832            if (!payload || typeof payload !== 'object') {
833                return;
834            }
835
836            var hasSessionId = Object.prototype.hasOwnProperty.call(payload, 'session_id');
837            var hasSessionToken = Object.prototype.hasOwnProperty.call(payload, 'session_token');
838
839            if (!hasSessionId && !hasSessionToken) {
840                return;
841            }
842
843            setSessionIdentifiers(
844                hasSessionId ? payload.session_id : state.session.id,
845                hasSessionToken ? payload.session_token : state.session.token
846            );
847        }
848
849        function updateConfigFromResponse(payload) {
850            if (payload && payload.waiting_phrases !== undefined) {
851                state.config.waiting_phrases = payload.waiting_phrases;
852            }
853            if (payload && payload.enable_tool_waiting_messages !== undefined) {
854                state.config.enable_tool_waiting_messages = payload.enable_tool_waiting_messages;
855            }
856        }
857
858        function getWaitingPhrases() {
859            var raw = state.config ? state.config.waiting_phrases : null;
860            if (!raw) {
861                return [];
862            }
863
864            if (Array.isArray(raw)) {
865                return raw.map(function (value) {
866                    return String(value || '').trim();
867                }).filter(Boolean);
868            }
869
870            if (typeof raw === 'string') {
871                return raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').map(function (value) {
872                    return String(value || '').trim();
873                }).filter(Boolean);
874            }
875
876            return [];
877        }
878
879        function stopLoadingPhraseRotation() {
880            if (loadingPhraseTimerId) {
881                global.clearInterval(loadingPhraseTimerId);
882                loadingPhraseTimerId = null;
883            }
884        }
885
886        var pickNextWaitingPhrase = function () {};
887
888        function startLoadingPhraseRotationForMessage(index) {
889            var phrases = getWaitingPhrases();
890            if (!phrases.length) {
891                pickNextWaitingPhrase = function () {};
892                return;
893            }
894
895            stopLoadingPhraseRotation();
896
897            var lastIndex = -1;
898
899            function pickNext() {
900                var message = state.messages[index];
901                if (!message || !message.loading) {
902                    stopLoadingPhraseRotation();
903                    return;
904                }
905                if (lastToolMessage !== null) {
906                    return; // gel : un message tool est actif
907                }
908
909                var nextIndex = Math.floor(Math.random() * phrases.length);
910                if (phrases.length > 1 && nextIndex === lastIndex) {
911                    nextIndex = (nextIndex + 1) % phrases.length;
912                }
913                lastIndex = nextIndex;
914
915                replaceMessageAt(index, Object.assign({}, message, {
916                    loading_phrase: phrases[nextIndex]
917                }));
918                notify('messages:changed');
919            }
920
921            pickNextWaitingPhrase = pickNext;
922
923            pickNext();
924            loadingPhraseTimerId = global.setInterval(pickNext, 6000);
925        }
926
927        function closeToolEventSource() {
928            if (toolEventSource) {
929                try { toolEventSource.close(); } catch (e) { /* noop */ }
930                toolEventSource = null;
931            }
932        }
933
934        function closeToolTransport() {
935            toolTransportGeneration += 1;
936            toolPollerStopped = true;
937            if (toolPollerTimerId) {
938                global.clearTimeout(toolPollerTimerId);
939                toolPollerTimerId = null;
940            }
941            closeToolEventSource();
942        }
943
944        function resetToolMessage() {
945            lastToolMessage = null;
946        }
947
948        function stopToolWaitingPoller() {
949            closeToolTransport();
950            resetToolMessage();
951        }
952
953        function applyToolMessage(index, newMessage) {
954            if (newMessage && newMessage !== lastToolMessage) {
955                lastToolMessage = newMessage;
956                var current = state.messages[index];
957                if (current && current.loading) {
958                    replaceMessageAt(index, Object.assign({}, current, {
959                        loading_phrase: newMessage
960                    }));
961                    notify('messages:changed');
962                }
963            } else if (!newMessage && lastToolMessage !== null) {
964                lastToolMessage = null;
965                pickNextWaitingPhrase();
966            }
967        }
968
969        function buildSessionScopedUrl(url) {
970            return url
971                + (url.indexOf('?') === -1 ? '?' : '&')
972                + 'session_id=' + encodeURIComponent(state.session.id)
973                + '&session_token=' + encodeURIComponent(state.session.token);
974        }
975
976        function startToolWaitingPollerLegacy(index) {
977            var url = state.endpoints && state.endpoints.toolStatusUrl;
978            if (!url) { return; }
979
980            closeToolTransport();
981            var generation = toolTransportGeneration;
982            toolPollerStopped = false;
983
984            function scheduleNext() {
985                if (toolPollerStopped || isStaleToolTransport(generation)) { return; }
986                toolPollerTimerId = global.setTimeout(poll, 1500);
987            }
988
989            function poll() {
990                if (toolPollerStopped || isStaleToolTransport(generation)) { return; }
991                var message = state.messages[index];
992                if (!message || !message.loading) {
993                    stopToolWaitingPoller();
994                    return;
995                }
996                if (!state.session.id || !state.session.token) {
997                    scheduleNext();
998                    return;
999                }
1000
1001                global.fetch(withFormKey(buildSessionScopedUrl(url)), { credentials: 'same-origin' })
1002                    .then(function (r) { return r && r.ok ? r.json() : null; })
1003                    .then(function (data) {
1004                        if (toolPollerStopped || isStaleToolTransport(generation) || !data) { return; }
1005                        var raw = data.message;
1006                        var newMessage = (raw && String(raw).trim()) || null;
1007                        applyToolMessage(index, newMessage);
1008                    })
1009                    .catch(function () { /* silencieux */ })
1010                    .finally(function () { scheduleNext(); });
1011            }
1012
1013            poll();
1014        }
1015
1016        function startToolWaitingPollerForMessage(index) {
1017            var streamUrl = state.endpoints && state.endpoints.toolStatusStreamUrl;
1018            var fallbackUrl = state.endpoints && state.endpoints.toolStatusUrl;
1019
1020            if (!streamUrl || typeof global.EventSource === 'undefined' || toolStreamFailures >= 1) {
1021                if (fallbackUrl) { startToolWaitingPollerLegacy(index); }
1022                return;
1023            }
1024            if (!state.session.id || !state.session.token) {
1025                if (fallbackUrl) { startToolWaitingPollerLegacy(index); }
1026                return;
1027            }
1028
1029            closeToolTransport();
1030            var generation = toolTransportGeneration;
1031            toolPollerStopped = false;
1032
1033            var es;
1034            try {
1035                es = new global.EventSource(withFormKey(buildSessionScopedUrl(streamUrl)));
1036            } catch (e) {
1037                toolStreamFailures += 1;
1038                if (fallbackUrl) { startToolWaitingPollerLegacy(index); }
1039                return;
1040            }
1041            toolEventSource = es;
1042
1043            es.addEventListener('message', function (ev) {
1044                if (toolPollerStopped || isStaleToolTransport(generation)) { return; }
1045                var current = state.messages[index];
1046                if (!current || !current.loading) {
1047                    stopToolWaitingPoller();
1048                    return;
1049                }
1050                var data;
1051                try { data = JSON.parse(ev.data); } catch (e) { return; }
1052                if (!data) { return; }
1053                var raw = data.message;
1054                var newMessage = (raw && String(raw).trim()) || null;
1055                applyToolMessage(index, newMessage);
1056            });
1057
1058            es.addEventListener('clear', function () {
1059                if (toolPollerStopped || isStaleToolTransport(generation)) { return; }
1060                applyToolMessage(index, null);
1061            });
1062
1063            // The generation check must come first in these last two handlers: unlike the
1064            // others they mutate shared state (closeToolEventSource, toolStreamFailures)
1065            // before testing toolPollerStopped, so a queued event from an abandoned stream
1066            // would close the *current* transport and stick the session on legacy polling.
1067            es.addEventListener('done', function () {
1068                if (isStaleToolTransport(generation)) { return; }
1069                closeToolEventSource();
1070                var message = state.messages[index];
1071                if (!toolPollerStopped && message && message.loading && fallbackUrl) {
1072                    startToolWaitingPollerLegacy(index);
1073                }
1074            });
1075
1076            es.onerror = function () {
1077                if (isStaleToolTransport(generation)) { return; }
1078                // Close immediately to prevent EventSource auto-reconnect
1079                // hammering the rate limiter on persistent 403/429/500 errors.
1080                closeToolEventSource();
1081                toolStreamFailures += 1;
1082                if (toolPollerStopped) { return; }
1083                var message = state.messages[index];
1084                if (message && message.loading && fallbackUrl) {
1085                    startToolWaitingPollerLegacy(index);
1086                }
1087            };
1088        }
1089
1090        function startLoadingIndicatorsForMessage(index) {
1091            startLoadingPhraseRotationForMessage(index);
1092            if (state.config && state.config.enable_tool_waiting_messages) {
1093                startToolWaitingPollerForMessage(index);
1094            }
1095        }
1096
1097        function stopLoadingIndicators() {
1098            stopLoadingPhraseRotation();
1099            stopToolWaitingPoller();
1100        }
1101
1102        function stopPendingAssistantPoller() {
1103            if (pendingAssistantPollerTimerId !== null) {
1104                clearInterval(pendingAssistantPollerTimerId);
1105                pendingAssistantPollerTimerId = null;
1106            }
1107        }
1108
1109        function startPendingAssistantPoller(baselineMessageCount) {
1110            stopPendingAssistantPoller();
1111
1112            var epoch = conversationEpoch;
1113            var pollerTimerId = null;
1114
1115            // pendingAssistantPollerTimerId is a single controller-wide handle, so a tick
1116            // whose request is still in flight can outlive its own poller — either because
1117            // the conversation ended, or because a reopen installed a replacement. Both
1118            // cases must abandon quietly: calling stopPendingAssistantPoller() here would
1119            // kill the *new* poller, and the old interval is already cleared.
1120            function isStalePoller() {
1121                return pendingAssistantPollerTimerId !== pollerTimerId || isStaleEpoch(epoch);
1122            }
1123
1124            pushMessage({
1125                content: '',
1126                loading: true,
1127                metadata: {},
1128                role: 'assistant'
1129            });
1130            var loadingIndex = state.messages.length - 1;
1131            state.ui.loading = true;
1132            notify('messages:changed');
1133            notify('ui:changed');
1134            startLoadingIndicatorsForMessage(loadingIndex);
1135
1136            var pollStartedAt = Date.now();
1137            var pollIntervalMs = 1500;
1138            var pollDeadlineMs = 120 * 1000;
1139            var pollInFlight = false;
1140
1141            function failPending(errorMessage) {
1142                stopPendingAssistantPoller();
1143                stopLoadingIndicators();
1144                replaceMessageAt(loadingIndex, {
1145                    content: 'Server error. Please check your connection.',
1146                    metadata: {},
1147                    role: 'assistant'
1148                });
1149                state.ui.loading = false;
1150                state.ui.lastError = errorMessage;
1151                notify('messages:changed');
1152                notify('ui:changed');
1153                notify('error', { error: errorMessage });
1154            }
1155
1156            pollerTimerId = setInterval(async function () {
1157                if (pollInFlight) {
1158                    return;
1159                }
1160                if (isStalePoller()) {
1161                    return;
1162                }
1163                if (Date.now() - pollStartedAt > pollDeadlineMs) {
1164                    failPending('pending_timeout');
1165                    return;
1166                }
1167                if (!state.session.id || !state.session.token || !state.endpoints.loadUrl) {
1168                    // The answer is unreachable now. failPending also hands ui.loading back
1169                    // and replaces the loading bubble: bailing out without it left the
1170                    // widget wedged behind that flag, with a spinner running forever.
1171                    failPending('pending_session_lost');
1172                    return;
1173                }
1174
1175                pollInFlight = true;
1176                try {
1177                    var pollResponse = await getJson(state.endpoints.loadUrl, {
1178                        session_id: state.session.id,
1179                        session_token: state.session.token
1180                    });
1181                    if (isStalePoller()) {
1182                        return;
1183                    }
1184
1185                    var serverMessages = (pollResponse && Array.isArray(pollResponse.messages))
1186                        ? pollResponse.messages
1187                        : [];
1188                    var lastServer = serverMessages.length
1189                        ? serverMessages[serverMessages.length - 1]
1190                        : null;
1191
1192                    if (serverMessages.length > baselineMessageCount
1193                        && lastServer
1194                        && lastServer.role === 'assistant') {
1195                        stopLoadingIndicators();
1196                        applySessionFromResponse(pollResponse);
1197                        updateConfigFromResponse(pollResponse);
1198                        setMessages(serverMessages);
1199                        clearUnavailableState();
1200                        state.diagnostics.lastResponseMeta = pollResponse;
1201                        state.ui.loading = false;
1202                        stopPendingAssistantPoller();
1203                        notify('messages:changed');
1204                        notify('session:changed');
1205                        notify('ui:changed');
1206                        return;
1207                    }
1208
1209                    if (pollResponse && pollResponse.assistant_pending === false) {
1210                        failPending('pending_aborted');
1211                    }
1212                } catch (error) {
1213                    state.diagnostics.lastTransportError = String(
1214                        error && error.message ? error.message : error
1215                    );
1216                } finally {
1217                    pollInFlight = false;
1218                }
1219            }, pollIntervalMs);
1220
1221            pendingAssistantPollerTimerId = pollerTimerId;
1222        }
1223
1224        function buildFallbackMessages(preferWelcome) {
1225            var initialOptions = getInitialUserOptionsFromConfig(state.config);
1226            var fallbackContent = preferWelcome ? (state.config.welcome_message || state.config.tagline) : state.config.tagline;
1227
1228            if (!fallbackContent) {
1229                return [];
1230            }
1231
1232            return [
1233                {
1234                    content: fallbackContent,
1235                    metadata: initialOptions ? { user_options: initialOptions } : {},
1236                    role: 'assistant'
1237                }
1238            ];
1239        }
1240
1241        async function request(url, options) {
1242            if (!url) {
1243                throw new Error('Missing URL');
1244            }
1245
1246            var fetchOptions = Object.assign({
1247                headers: {}
1248            }, options || {});
1249            fetchOptions.headers = Object.assign({}, fetchOptions.headers || {});
1250
1251            var response = await fetch(withFormKey(url), fetchOptions);
1252            var payload;
1253
1254            try {
1255                payload = await response.json();
1256            } catch (error) {
1257                payload = null;
1258            }
1259
1260            if (!response.ok) {
1261                var requestError = new Error('HTTP ' + response.status);
1262                requestError.payload = payload;
1263                throw requestError;
1264            }
1265
1266            return payload;
1267        }
1268
1269        async function getJson(url, query) {
1270            var finalUrl = url;
1271            if (query && typeof query === 'object') {
1272                var search = new URLSearchParams(query).toString();
1273                if (search) {
1274                    finalUrl += (finalUrl.indexOf('?') === -1 ? '?' : '&') + search;
1275                }
1276            }
1277
1278            return request(finalUrl, {
1279                headers: {
1280                    Accept: 'application/json'
1281                },
1282                method: 'GET'
1283            });
1284        }
1285
1286        async function postJson(url, payload) {
1287            return request(url, {
1288                body: JSON.stringify(payload || {}),
1289                headers: {
1290                    'Content-Type': 'application/json'
1291                },
1292                method: 'POST'
1293            });
1294        }
1295
1296        async function postForm(url, payload) {
1297            return request(url, {
1298                body: new URLSearchParams(payload || {}).toString(),
1299                headers: {
1300                    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
1301                },
1302                method: 'POST'
1303            });
1304        }
1305
1306        async function ensureSessionIdSilent() {
1307            if (state.session.id) {
1308                return state.session.id;
1309            }
1310
1311            var epoch = conversationEpoch;
1312            var payload = await postJson(state.endpoints.startUrl, {});
1313
1314            // Returning state.session.id would hand the caller the *new* conversation's id
1315            // when the user ended this one mid-request.
1316            if (isStaleEpoch(epoch)) {
1317                return null;
1318            }
1319
1320            updateConfigFromResponse(payload);
1321            applySessionFromResponse(payload);
1322            notify('session:changed');
1323            return state.session.id;
1324        }
1325
1326        async function persistPreAnsweredOption(message, preAnswer) {
1327            var epoch = conversationEpoch;
1328            var sessionId = await ensureSessionIdSilent();
1329            if (isStaleEpoch(epoch) || !sessionId || !state.endpoints.sendUrl) {
1330                return;
1331            }
1332
1333            var response = await postJson(state.endpoints.sendUrl, {
1334                message: message,
1335                page_url: settings.locationHref || global.location.href,
1336                pre_answer: preAnswer,
1337                session_id: sessionId,
1338                session_token: state.session.token,
1339                silent: 1
1340            });
1341
1342            if (isStaleEpoch(epoch)) {
1343                return;
1344            }
1345
1346            applySessionFromResponse(response);
1347            notify('session:changed');
1348        }
1349
1350        async function trackEscalationClick() {
1351            if (!state.session.id || !state.session.token || !state.config.track_escalation_click_url) {
1352                return false;
1353            }
1354
1355            var epoch = conversationEpoch;
1356
1357            try {
1358                await postJson(state.config.track_escalation_click_url, {
1359                    session_id: state.session.id,
1360                    session_token: state.session.token
1361                });
1362                return true;
1363            } catch (error) {
1364                if (isStaleEpoch(epoch)) {
1365                    return false;
1366                }
1367
1368                state.diagnostics.lastTransportError = String(error && error.message ? error.message : error);
1369                notify('error', { error: state.diagnostics.lastTransportError });
1370                return false;
1371            }
1372        }
1373
1374        async function loadSessionMessages() {
1375            if (state.ui.loadingMessages) {
1376                return false;
1377            }
1378
1379            var epoch = conversationEpoch;
1380
1381            if (!state.session.id || !state.session.token || !state.endpoints.loadUrl) {
1382                if (state.ui.visible && state.endpoints.startUrl) {
1383                    clearSessionIdentifiers();
1384                    notify('session:changed');
1385                    await startSession();
1386                    return !isStaleEpoch(epoch);
1387                }
1388                return false;
1389            }
1390
1391            state.ui.loadingMessages = true;
1392            state.ui.lastError = null;
1393
1394            // A pending-assistant poller owns ui.loading and indexes into the message list
1395            // every exit path below replaces. Reclaim both before the request, not after:
1396            // the catch and the blocking-payload branch also call setMessages, and the
1397            // catch clears the session identifiers, which stops the poller without ever
1398            // handing the flag back — leaving the composer disabled for good.
1399            if (pendingAssistantPollerTimerId !== null) {
1400                stopPendingAssistantPoller();
1401                stopLoadingIndicators();
1402                state.ui.loading = false;
1403            }
1404
1405            notify('ui:changed');
1406
1407            try {
1408                var response = await getJson(state.endpoints.loadUrl, {
1409                    session_id: state.session.id,
1410                    session_token: state.session.token
1411                });
1412
1413                if (isStaleEpoch(epoch)) {
1414                    return false;
1415                }
1416
1417                applySessionFromResponse(response);
1418                updateConfigFromResponse(response);
1419                setMessages(response && Array.isArray(response.messages) ? response.messages : []);
1420                clearUnavailableState();
1421                state.diagnostics.lastResponseMeta = response;
1422                notify('messages:changed');
1423                notify('session:changed');
1424
1425                if (response && response.assistant_pending === true) {
1426                    startPendingAssistantPoller((response.messages || []).length);
1427                }
1428
1429                return true;
1430            } catch (error) {
1431                if (isStaleEpoch(epoch)) {
1432                    return false;
1433                }
1434
1435                var blockingPayload = serverBlockingPayload(error);
1436                if (blockingPayload) {
1437                    applyUnavailableState(blockingPayload);
1438                    setMessages([buildUnavailableMessage(blockingPayload)]);
1439                    state.ui.lastError = null;
1440                    notify('messages:changed');
1441                    notify('error', { error: blockingPayload.error || 'unavailable' });
1442                    return false;
1443                }
1444                clearSessionIdentifiers();
1445                setMessages(buildFallbackMessages(false));
1446                state.ui.lastError = String(error && error.message ? error.message : error);
1447                notify('messages:changed');
1448                notify('session:changed');
1449                notify('error', { error: state.ui.lastError });
1450                return false;
1451            } finally {
1452                if (!isStaleEpoch(epoch)) {
1453                    state.ui.loadingMessages = false;
1454                    notify('ui:changed');
1455                }
1456            }
1457        }
1458
1459        function serverBlockingPayload(error) {
1460            // A structured blocking response carries a user-facing `message`
1461            // (llm_unavailable, chatbot_disabled, admin restrictions). Surface it
1462            // as an assistant bubble and lock the composer instead of masking the
1463            // outage behind the normal greeting / a bare "Server error".
1464            var payload = error && error.payload;
1465            if (payload && typeof payload === 'object'
1466                && typeof payload.message === 'string' && payload.message.trim() !== '') {
1467                return payload;
1468            }
1469            return null;
1470        }
1471
1472        function buildUnavailableMessage(payload) {
1473            var content = (payload && payload.message) ? String(payload.message) : 'Le service IA est temporairement indisponible.';
1474            var metadata = {};
1475            if (payload && payload.mode === 'escalation' && payload.escalation) {
1476                metadata.escalation = payload.escalation;
1477            }
1478            return {
1479                content: content,
1480                metadata: metadata,
1481                role: 'assistant'
1482            };
1483        }
1484
1485        function applyUnavailableState(payload) {
1486            state.ui.unavailable = true;
1487            state.ui.inputDisabled = true;
1488        }
1489
1490        function clearUnavailableState() {
1491            if (state.ui.unavailable || state.ui.inputDisabled) {
1492                state.ui.unavailable = false;
1493                state.ui.inputDisabled = false;
1494            }
1495        }
1496
1497        async function startSession() {
1498            if (state.ui.loading || !state.endpoints.startUrl) {
1499                return false;
1500            }
1501
1502            var epoch = conversationEpoch;
1503
1504            state.ui.loading = true;
1505            state.ui.lastError = null;
1506            notify('ui:changed');
1507
1508            if (!state.session.id && state.messages.length === 0) {
1509                pushMessage({
1510                    content: '',
1511                    loading: true,
1512                    metadata: {},
1513                    role: 'assistant'
1514                });
1515                startLoadingPhraseRotationForMessage(state.messages.length - 1);
1516                notify('messages:changed');
1517            }
1518
1519            try {
1520                var response = await postJson(state.endpoints.startUrl, {});
1521
1522                if (isStaleEpoch(epoch)) {
1523                    return false;
1524                }
1525
1526                applySessionFromResponse(response);
1527                updateConfigFromResponse(response);
1528
1529                if (response && Array.isArray(response.messages) && response.messages.length) {
1530                    setMessages(response.messages);
1531                } else {
1532                    setMessages(buildFallbackMessages(true));
1533                }
1534
1535                clearUnavailableState();
1536                state.diagnostics.lastResponseMeta = response;
1537                notify('messages:changed');
1538                notify('session:changed');
1539                return true;
1540            } catch (error) {
1541                if (isStaleEpoch(epoch)) {
1542                    return false;
1543                }
1544
1545                var blockingPayload = serverBlockingPayload(error);
1546                if (blockingPayload) {
1547                    applyUnavailableState(blockingPayload);
1548                    setMessages([buildUnavailableMessage(blockingPayload)]);
1549                    state.ui.lastError = null;
1550                    notify('messages:changed');
1551                    notify('error', { error: blockingPayload.error || 'unavailable' });
1552                    return false;
1553                }
1554                setMessages(buildFallbackMessages(false).length ? buildFallbackMessages(false) : [{
1555                    content: 'Server error',
1556                    metadata: {},
1557                    role: 'assistant'
1558                }]);
1559                state.ui.lastError = String(error && error.message ? error.message : error);
1560                notify('messages:changed');
1561                notify('error', { error: state.ui.lastError });
1562                return false;
1563            } finally {
1564                if (!isStaleEpoch(epoch)) {
1565                    state.ui.loading = false;
1566                    notify('ui:changed');
1567                }
1568            }
1569        }
1570
1571        async function sendMessage(message, extraPayload) {
1572            var payload = extraPayload || {};
1573            var text = String(message || '').trim();
1574
1575            if (!text || state.ui.loading || state.ui.loadingMessages || state.ui.sending || !state.endpoints.sendUrl) {
1576                return false;
1577            }
1578
1579            stopPendingAssistantPoller();
1580
1581            var epoch = conversationEpoch;
1582
1583            // `sending` alone guards reentrancy while the session is being created:
1584            // startSession() bails on `ui.loading`, so that flag must not be set yet.
1585            // Setting it here is what used to make every send without a session return
1586            // false, bouncing the typed text back into the composer for good.
1587            state.ui.sending = true;
1588            state.ui.lastError = null;
1589            notify('ui:changed');
1590
1591            if (!state.session.id) {
1592                var started = await startSession();
1593
1594                if (isStaleEpoch(epoch)) {
1595                    return false;
1596                }
1597
1598                if (!started || !state.session.id) {
1599                    state.ui.sending = false;
1600                    notify('ui:changed');
1601                    return false;
1602                }
1603            }
1604
1605            state.ui.loading = true;
1606            notify('ui:changed');
1607
1608            pushMessage({
1609                content: text,
1610                metadata: {},
1611                role: 'user'
1612            });
1613            pushMessage({
1614                content: '',
1615                loading: true,
1616                metadata: {},
1617                role: 'assistant'
1618            });
1619
1620            var loadingIndex = state.messages.length - 1;
1621            startLoadingIndicatorsForMessage(loadingIndex);
1622            notify('messages:changed');
1623
1624            try {
1625                var requestPayload = Object.assign({
1626                    message: text,
1627                    page_url: settings.locationHref || global.location.href,
1628                    session_id: state.session.id,
1629                    session_token: state.session.token
1630                }, payload || {});
1631
1632                var response = await postJson(state.endpoints.sendUrl, requestPayload);
1633
1634                if (isStaleEpoch(epoch)) {
1635                    return false;
1636                }
1637
1638                applySessionFromResponse(response);
1639                updateConfigFromResponse(response);
1640
1641                var metadata = {
1642                    closure_confirmation_question: response ? response.closure_confirmation_question : null,
1643                    debug: response ? (response.debug || null) : null,
1644                    escalation: response ? response.escalation : null,
1645                    ready_to_close: response ? response.ready_to_close : null,
1646                    should_ask_closure_confirmation: response ? response.should_ask_closure_confirmation : null,
1647                    user_options: response ? response.user_options : null
1648                };
1649
1650                var assistantContent = response && response.response ? response.response : 'An error occurred. Please try again.';
1651                if (response && response.ready_to_close && response.end_conversation_message) {
1652                    assistantContent = response.end_conversation_message;
1653                }
1654                if (response && response.error) {
1655                    assistantContent = response.error;
1656                }
1657
1658                replaceMessageAt(loadingIndex, {
1659                    content: assistantContent,
1660                    metadata: metadata,
1661                    role: 'assistant'
1662                });
1663
1664                clearUnavailableState();
1665                state.diagnostics.lastResponseMeta = response;
1666                notify('messages:changed');
1667                notify('session:changed');
1668                return true;
1669            } catch (error) {
1670                if (isStaleEpoch(epoch)) {
1671                    return false;
1672                }
1673
1674                var blockingPayload = serverBlockingPayload(error);
1675                if (blockingPayload) {
1676                    applyUnavailableState(blockingPayload);
1677                    replaceMessageAt(loadingIndex, buildUnavailableMessage(blockingPayload));
1678                    state.ui.lastError = null;
1679                    notify('messages:changed');
1680                    notify('error', { error: blockingPayload.error || 'unavailable' });
1681                    return false;
1682                }
1683                replaceMessageAt(loadingIndex, {
1684                    content: 'Server error. Please check your connection.',
1685                    metadata: {},
1686                    role: 'assistant'
1687                });
1688                state.ui.lastError = String(error && error.message ? error.message : error);
1689                notify('messages:changed');
1690                notify('error', { error: state.ui.lastError });
1691                return false;
1692            } finally {
1693                // Skipped wholesale when stale: stopLoadingIndicators() would purge the
1694                // *new* conversation's phrase rotation and tool transport, and the flags
1695                // were already reset by whoever bumped the epoch.
1696                if (!isStaleEpoch(epoch)) {
1697                    stopLoadingIndicators();
1698                    state.ui.loading = false;
1699                    state.ui.sending = false;
1700                    notify('ui:changed');
1701                }
1702            }
1703        }
1704
1705        async function selectOption(messageIndex, option) {
1706            removeUserOptionsAt(messageIndex);
1707            notify('messages:changed');
1708
1709            if (!option || typeof option !== 'object') {
1710                return false;
1711            }
1712
1713            var message = String(option.message || '').trim();
1714            var preAnswer = String(option.pre_answer || '').trim();
1715
1716            if (!message) {
1717                return false;
1718            }
1719
1720            if (preAnswer) {
1721                pushMessage({
1722                    content: message,
1723                    metadata: {},
1724                    role: 'user'
1725                });
1726                pushMessage({
1727                    content: preAnswer,
1728                    metadata: {},
1729                    role: 'assistant'
1730                });
1731                notify('messages:changed');
1732                await persistPreAnsweredOption(message, preAnswer);
1733                return true;
1734            }
1735
1736            return sendMessage(message);
1737        }
1738
1739        async function createSupportTicket() {
1740            if (state.ui.loading || state.ui.loadingMessages || state.ui.sending) {
1741                return false;
1742            }
1743
1744            return sendMessage(translations.createTicketMessage, {
1745                action: 'create_ticket'
1746            });
1747        }
1748
1749        async function closeConversation() {
1750            invalidateInFlightRequests();
1751            stopPendingAssistantPoller();
1752            stopLoadingIndicators();
1753            state.ui.loading = false;
1754            state.ui.loadingMessages = false;
1755            state.ui.sending = false;
1756            state.messages = [];
1757            syncConversationFlags();
1758            setVisible(false);
1759
1760            // Capture before clearing: the close request still needs the identifiers, but
1761            // the storage keys must be dropped now. Clearing them after the await would let
1762            // this continuation wipe a brand-new session started by a fast reopen.
1763            var closingSessionId = state.session.id;
1764            var closingSessionToken = state.session.token || '';
1765            clearSessionIdentifiers();
1766
1767            // Emitted before the await too: a reopen during a slow close request would
1768            // otherwise find the old draft still sitting in the composer.
1769            notify('composer:reset');
1770            notify('messages:changed');
1771            notify('session:changed');
1772            notify('ui:changed');
1773
1774            if (closingSessionId && state.endpoints.closeUrl) {
1775                try {
1776                    await postForm(state.endpoints.closeUrl, {
1777                        session_id: closingSessionId,
1778                        session_token: closingSessionToken
1779                    });
1780                } catch (error) {
1781                    state.diagnostics.lastTransportError = String(error && error.message ? error.message : error);
1782                }
1783            }
1784
1785            return true;
1786        }
1787
1788        function resetChatbox() {
1789            invalidateInFlightRequests();
1790            stopPendingAssistantPoller();
1791            stopLoadingIndicators();
1792            state.ui.loading = false;
1793            state.ui.loadingMessages = false;
1794            state.ui.sending = false;
1795            clearUnavailableState();
1796            state.messages = [];
1797            syncConversationFlags();
1798            setVisible(false);
1799            clearSessionIdentifiers();
1800            notify('composer:reset');
1801            notify('messages:changed');
1802            notify('session:changed');
1803            notify('ui:changed');
1804        }
1805
1806        async function open() {
1807            if (state.ui.loadingMessages) {
1808                return false;
1809            }
1810
1811            setVisible(true);
1812            notify('ui:changed');
1813
1814            if (state.session.id && state.session.token && state.endpoints.loadUrl) {
1815                return loadSessionMessages();
1816            }
1817
1818            clearSessionIdentifiers();
1819            notify('session:changed');
1820            return startSession();
1821        }
1822
1823        function hide() {
1824            setVisible(false);
1825            notify('ui:changed');
1826        }
1827
1828        async function toggle() {
1829            if (state.ui.visible) {
1830                hide();
1831                return true;
1832            }
1833
1834            return open();
1835        }
1836
1837        async function init() {
1838            setSessionIdentifiers(
1839                storageGet(getStorageKey('session_id'), null),
1840                storageGet(getStorageKey('session_token'), null)
1841            );
1842
1843            if (!settings.isAdmin && !state.session.id) {
1844                var legacySessionId = storageGet('webcooking_ai_chat_session_id', null);
1845                if (legacySessionId) {
1846                    setSessionIdentifiers(legacySessionId, state.session.token);
1847                    storageRemove('webcooking_ai_chat_session_id');
1848                }
1849            }
1850
1851            state.session.hydrated = true;
1852            state.session.hasPersistedSession = !!(state.session.id && state.session.token);
1853            state.ui.visible = keepsWindowOpenAcrossPages()
1854                && storageGet(getStorageKey('visible'), '0') === '1';
1855            notify('session:changed');
1856            notify('ui:changed');
1857
1858            if (state.ui.visible) {
1859                if (state.session.id && state.session.token && state.endpoints.loadUrl) {
1860                    await loadSessionMessages();
1861                } else {
1862                    clearSessionIdentifiers();
1863                    notify('session:changed');
1864                    await startSession();
1865                }
1866            } else if (state.session.id && state.session.token && state.endpoints.loadUrl) {
1867                await loadSessionMessages();
1868            }
1869
1870            return publicState();
1871        }
1872
1873        return {
1874            closeConversation: closeConversation,
1875            createSupportTicket: createSupportTicket,
1876            getState: publicState,
1877            hide: hide,
1878            init: init,
1879            keepsWindowOpenAcrossPages: keepsWindowOpenAcrossPages,
1880            loadSessionMessages: loadSessionMessages,
1881            open: open,
1882            resetChatbox: resetChatbox,
1883            selectOption: selectOption,
1884            sendMessage: sendMessage,
1885            startSession: startSession,
1886            subscribe: subscribe,
1887            toggle: toggle,
1888            trackEscalationClick: trackEscalationClick
1889        };
1890    }
1891
1892    namespace.buildDebugPayload = buildDebugPayload;
1893    namespace.createChatController = createChatController;
1894    namespace.escapeHtml = escapeHtml;
1895    namespace.getInitialUserOptionsFromConfig = getInitialUserOptionsFromConfig;
1896    namespace.getUserOptionsList = getUserOptionsList;
1897    namespace.helpers = Object.assign({}, namespace.helpers || {}, {
1898        assistantSanitizedHtml: assistantSanitizedHtml,
1899        buildDebugPayload: buildDebugPayload,
1900        escapeHtml: escapeHtml,
1901        getInitialUserOptionsFromConfig: getInitialUserOptionsFromConfig,
1902        getUserOptionsList: getUserOptionsList,
1903        isAllowedHrefUrl: isAllowedHrefUrl,
1904        isInteractiveTarget: isInteractiveTarget,
1905        isTouchPrimaryDevice: isTouchPrimaryDevice,
1906        onTouchPrimaryChange: onTouchPrimaryChange,
1907        sanitizeAssistantHtml: sanitizeAssistantHtml
1908    });
1909    namespace.isAllowedHrefUrl = isAllowedHrefUrl;
1910    namespace.openDebugPopup = openDebugPopup;
1911
1912    global.WebcookingAiChatbot = namespace;
1913})(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.