| @@ -1306,26 +1306,11 @@ | ||
| 1306 | 1306 | let testingDataReceived = false; |
| 1307 | 1307 | let streamingStarted = false; |
| 1308 | 1308 | // Server-pushed html to append as its OWN bot bubble once the stream |
| 1309 | 1309 | // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is |
| 1310 | - // deferred to the end of the stream so the embed always lands BELOW the | |
| 1311 | - // streamed text. | |
| 1310 | + // deferred to [DONE] so the embed always lands BELOW the streamed text. | |
| 1312 | 1311 | let pendingAppendHtml = ''; |
| 1313 | 1312 | |
| 1314 | - // Render the stashed appendix as its own bot bubble below the streamed | |
| 1315 | - // text — mirrors how it is saved in the transcript, so history replays | |
| 1316 | - // identically. Called from BOTH ends of a stream: the [DONE] line and | |
| 1317 | - // the stream simply closing (the OpenAI Responses path and Gemini end | |
| 1318 | - // without a [DONE] line). The stash is cleared, so a stream that sends | |
| 1319 | - // [DONE] and then closes renders it once. Any future appendix-type | |
| 1320 | - // event belongs in here, not in one of the two branches. | |
| 1321 | - function flushAppendHtml() { | |
| 1322 | - if (pendingAppendHtml) { | |
| 1323 | - appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1324 | - pendingAppendHtml = ''; | |
| 1325 | - } | |
| 1326 | - } | |
| 1327 | - | |
| 1328 | 1313 | // Abortable stream: a fresh controller per turn, keyed by bot instance. |
| 1329 | 1314 | // The Stop control (send button swapped in place) aborts both the read |
| 1330 | 1315 | // loop and the underlying request. |
| 1331 | 1316 | var streamControl = { controller: new AbortController(), aborted: false }; |
| @@ -1406,10 +1391,8 @@ | ||
| 1406 | 1391 | |
| 1407 | 1392 | // Re-enable chat input when stream ends with content |
| 1408 | 1393 | enableChatInput(botId); |
| 1409 | 1394 | |
| 1410 | - flushAppendHtml(); | |
| 1411 | - | |
| 1412 | 1395 | // Scroll the user's last message to the top now that the |
| 1413 | 1396 | // bot's full reply has rendered (gives max reading room). |
| 1414 | 1397 | var $chatBoxDone = getElement(botId, 'chat-box'); |
| 1415 | 1398 | var $lastUserMsgDone = $chatBoxDone.find('.user-message').last(); |
| @@ -1440,9 +1423,16 @@ | ||
| 1440 | 1423 | |
| 1441 | 1424 | // Re-enable chat input after streaming completes |
| 1442 | 1425 | enableChatInput(botId); |
| 1443 | 1426 | |
| 1444 | - flushAppendHtml(); | |
| 1427 | + // Render any server-pushed appendix html (e.g. the | |
| 1428 | + // YouTube embed) as its own bot bubble below the | |
| 1429 | + // streamed text — mirrors how it is saved in the | |
| 1430 | + // transcript, so history replays identically. | |
| 1431 | + if (pendingAppendHtml) { | |
| 1432 | + appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1433 | + pendingAppendHtml = ''; | |
| 1434 | + } | |
| 1445 | 1435 | |
| 1446 | 1436 | // Scroll the user's last message to the top now |
| 1447 | 1437 | // that the bot's full reply has rendered. |
| 1448 | 1438 | var $chatBoxStreamDone = getElement(botId, 'chat-box'); |
| @@ -2261,56 +2251,8 @@ | ||
| 2261 | 2251 | // Error rendering message - silently continue |
| 2262 | 2252 | } |
| 2263 | 2253 | } |
| 2264 | 2254 | |
| 2265 | -// Link clicks in bot and agent messages: the click is recorded, the browser | |
| 2266 | -// follows the anchor itself. Navigation must not wait for the tracking | |
| 2267 | -// request: a window.open() made from that request's callback is outside the | |
| 2268 | -// visitor's tap, and Safari's pop-up blocker drops it without a message. | |
| 2269 | - | |
| 2270 | -// Give a tracked anchor the target it should open in, and noopener on a new tab. | |
| 2271 | -function prepareTrackedLink($link) { | |
| 2272 | - if (linkTarget === '_blank' && $link.attr('target') !== '_blank') { | |
| 2273 | - $link.attr('target', '_blank'); | |
| 2274 | - } | |
| 2275 | - if ($link.attr('target') === '_blank') { | |
| 2276 | - var rel = $link.attr('rel') || ''; | |
| 2277 | - if (!/(^|\s)noopener(\s|$)/.test(rel)) { | |
| 2278 | - $link.attr('rel', $.trim(rel + ' noopener')); | |
| 2279 | - } | |
| 2280 | - } | |
| 2281 | -} | |
| 2282 | - | |
| 2283 | -// Record one click, fire-and-forget. sendBeacon survives the page unloading | |
| 2284 | -// when the link opens in the same tab. | |
| 2285 | -function trackLinkClick(href, messageContext, botId) { | |
| 2286 | - var fields = { | |
| 2287 | - action: 'mxchat_track_url_click', | |
| 2288 | - session_id: getChatSession(botId), | |
| 2289 | - url: href, | |
| 2290 | - message_context: messageContext, | |
| 2291 | - nonce: mxchatChat.nonce | |
| 2292 | - }; | |
| 2293 | - try { | |
| 2294 | - if (navigator.sendBeacon && typeof FormData !== 'undefined') { | |
| 2295 | - var body = new FormData(); | |
| 2296 | - $.each(fields, function(name, value) { | |
| 2297 | - body.append(name, value == null ? '' : value); | |
| 2298 | - }); | |
| 2299 | - if (navigator.sendBeacon(mxchatChat.ajax_url, body)) { | |
| 2300 | - return; | |
| 2301 | - } | |
| 2302 | - } | |
| 2303 | - } catch (error) { | |
| 2304 | - // Fall through to the plain request | |
| 2305 | - } | |
| 2306 | - $.ajax({ | |
| 2307 | - url: mxchatChat.ajax_url, | |
| 2308 | - type: 'POST', | |
| 2309 | - data: fields | |
| 2310 | - }); | |
| 2311 | -} | |
| 2312 | - | |
| 2313 | 2255 | // Helper function to attach link tracking with proper event handling |
| 2314 | 2256 | function attachLinkTracking(messageDiv, messageText, botId) { |
| 2315 | 2257 | botId = botId || 'default'; |
| 2316 | 2258 | // Use a slight delay to ensure DOM is ready |
| @@ -2328,13 +2270,11 @@ | ||
| 2328 | 2270 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 2329 | 2271 | // Remove any existing click handlers first |
| 2330 | 2272 | $link.off('click.tracking'); |
| 2331 | 2273 | |
| 2332 | - // The anchor carries its own target: the browser follows it | |
| 2333 | - prepareTrackedLink($link); | |
| 2334 | - | |
| 2335 | 2274 | // Add new click handler with namespace |
| 2336 | 2275 | $link.on('click.tracking', function(e) { |
| 2276 | + e.preventDefault(); | |
| 2337 | 2277 | e.stopPropagation(); |
| 2338 | 2278 | |
| 2339 | 2279 | const messageContext = typeof messageText === 'string' |
| 2340 | 2280 | ? messageText.substring(0, 200) |
| @@ -2339,10 +2279,30 @@ | ||
| 2339 | 2279 | const messageContext = typeof messageText === 'string' |
| 2340 | 2280 | ? messageText.substring(0, 200) |
| 2341 | 2281 | : ''; |
| 2342 | 2282 | |
| 2343 | - prepareTrackedLink($link); | |
| 2344 | - trackLinkClick(originalHref, messageContext, botId); | |
| 2283 | + // Track the click | |
| 2284 | + $.ajax({ | |
| 2285 | + url: mxchatChat.ajax_url, | |
| 2286 | + type: 'POST', | |
| 2287 | + data: { | |
| 2288 | + action: 'mxchat_track_url_click', | |
| 2289 | + session_id: getChatSession(botId), | |
| 2290 | + url: originalHref, | |
| 2291 | + message_context: messageContext, | |
| 2292 | + nonce: mxchatChat.nonce | |
| 2293 | + }, | |
| 2294 | + complete: function() { | |
| 2295 | + // Always redirect, even if tracking fails | |
| 2296 | + if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 2297 | + window.open(originalHref, '_blank'); | |
| 2298 | + } else { | |
| 2299 | + window.location.href = originalHref; | |
| 2300 | + } | |
| 2301 | + } | |
| 2302 | + }); | |
| 2303 | + | |
| 2304 | + return false; // Extra insurance to prevent default | |
| 2345 | 2305 | }); |
| 2346 | 2306 | } |
| 2347 | 2307 | }); |
| 2348 | 2308 | }, 100); // Small delay to ensure DOM is ready |
| @@ -2637,17 +2597,9 @@ | ||
| 2637 | 2597 | }); |
| 2638 | 2598 | |
| 2639 | 2599 | // Process standalone URLs - but NOT if they're already in <a> tags or brackets |
| 2640 | 2600 | // Updated pattern to be more careful about what it matches |
| 2641 | - // | |
| 2642 | - // The prefix group (plan 78618d) is the start of a line, an ordinary | |
| 2643 | - // character, OR a tag this formatter itself opened or closed (paragraph, | |
| 2644 | - // list item, table cell, heading, emphasis, line break), so an address | |
| 2645 | - // that opens a paragraph or sits inside **bold** is linked too. Never | |
| 2646 | - // an anchor (its text would be linked twice; the lookahead guards that | |
| 2647 | - // as well) and never a code span, which stays as typed. No lookbehind, | |
| 2648 | - // for Safari. | |
| 2649 | - const urlPattern = /(^|[^">=\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2601 | + const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2650 | 2602 | processedText = processedText.replace(urlPattern, (match, prefix, url) => { |
| 2651 | 2603 | // Extra check: make sure this isn't already linked |
| 2652 | 2604 | if (match.includes('href=') || match.includes('</a>')) { |
| 2653 | 2605 | return match; |
| @@ -2658,14 +2610,10 @@ | ||
| 2658 | 2610 | const safeUrl = safeEncodeUrl(cleanUrl); |
| 2659 | 2611 | return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`; |
| 2660 | 2612 | }); |
| 2661 | 2613 | |
| 2662 | - // Process www. URLs - but NOT if they're already in <a> tags or brackets. | |
| 2663 | - // The address stops at the same characters the https pattern stops at | |
| 2664 | - // (plan 78618d): it used to run through the closing paragraph tag, so | |
| 2665 | - // "Try www.example.com/page." linked to www.example.com/page.%3C/p%3E. | |
| 2666 | - // Same prefix group as the https pattern, same reasons. | |
| 2667 | - const wwwPattern = /(^|[^">/\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(www\.[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2614 | + // Process www. URLs - but NOT if they're already in <a> tags or brackets | |
| 2615 | + const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim; | |
| 2668 | 2616 | processedText = processedText.replace(wwwPattern, (match, prefix, url) => { |
| 2669 | 2617 | // Extra check: make sure this isn't already linked |
| 2670 | 2618 | if (match.includes('href=') || match.includes('</a>')) { |
| 2671 | 2619 | return match; |
| @@ -2729,19 +2677,13 @@ | ||
| 2729 | 2677 | protectedSegments.push(match); |
| 2730 | 2678 | return placeholder; |
| 2731 | 2679 | }); |
| 2732 | 2680 | |
| 2733 | - // Step 1c: Also protect bare URLs that might exist. Trailing emphasis | |
| 2734 | - // markers are not part of the address (plan 78618d): **https://x.com** | |
| 2735 | - // used to protect "https://x.com**", so the bold never paired and the | |
| 2736 | - // asterisks ended up inside the link. The markers stay outside the | |
| 2737 | - // placeholder so the styling rules below can pair them. | |
| 2681 | + // Step 1c: Also protect bare URLs that might exist | |
| 2738 | 2682 | protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) { |
| 2739 | - const markers = match.match(/[*_~]+$/); | |
| 2740 | - const url = markers ? match.slice(0, match.length - markers[0].length) : match; | |
| 2741 | 2683 | const placeholder = `__PROTECTED_${protectedSegments.length}__`; |
| 2742 | - protectedSegments.push(url); | |
| 2743 | - return placeholder + (markers ? markers[0] : ''); | |
| 2684 | + protectedSegments.push(match); | |
| 2685 | + return placeholder; | |
| 2744 | 2686 | }); |
| 2745 | 2687 | |
| 2746 | 2688 | // Step 2: Now apply text styling to the protected text |
| 2747 | 2689 | // Handle bold text (**text**) |
| @@ -4608,8 +4550,14 @@ | ||
| 4608 | 4550 | if (messageDiv.length > 0) { |
| 4609 | 4551 | const originalHref = $link.attr('href'); |
| 4610 | 4552 | |
| 4611 | 4553 | if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) { |
| 4554 | + e.preventDefault(); | |
| 4555 | + e.stopPropagation(); | |
| 4556 | + | |
| 4557 | + // Mark as tracked | |
| 4558 | + $link.attr('data-tracked', 'true'); | |
| 4559 | + | |
| 4612 | 4560 | // Get bot ID from the chat box context |
| 4613 | 4561 | var botId = getBotIdFromElement(this); |
| 4614 | 4562 | |
| 4615 | 4563 | // Get message context from the message div |
| @@ -4614,12 +4562,28 @@ | ||
| 4614 | 4562 | |
| 4615 | 4563 | // Get message context from the message div |
| 4616 | 4564 | const messageText = messageDiv.text().substring(0, 200); |
| 4617 | 4565 | |
| 4618 | - // Same path as attachLinkTracking(): set the target, record the | |
| 4619 | - // click, and let the browser follow the anchor. | |
| 4620 | - prepareTrackedLink($link); | |
| 4621 | - trackLinkClick(originalHref, messageText, botId); | |
| 4566 | + $.ajax({ | |
| 4567 | + url: mxchatChat.ajax_url, | |
| 4568 | + type: 'POST', | |
| 4569 | + data: { | |
| 4570 | + action: 'mxchat_track_url_click', | |
| 4571 | + session_id: getChatSession(botId), | |
| 4572 | + url: originalHref, | |
| 4573 | + message_context: messageText, | |
| 4574 | + nonce: mxchatChat.nonce | |
| 4575 | + }, | |
| 4576 | + complete: function() { | |
| 4577 | + if ($link.attr('target') === '_blank' || linkTarget === '_blank') { | |
| 4578 | + window.open(originalHref, '_blank'); | |
| 4579 | + } else { | |
| 4580 | + window.location.href = originalHref; | |
| 4581 | + } | |
| 4582 | + } | |
| 4583 | + }); | |
| 4584 | + | |
| 4585 | + return false; | |
| 4622 | 4586 | } |
| 4623 | 4587 | } |
| 4624 | 4588 | }); |
| 4625 | 4589 | |