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 = '×'; 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.