PageSourceSearch

https://udx.io/wp-content/static/js/udx-docs-chat.js?v=1790159375

js udx.io collected 2026-09-24 18:37:19 UTC 30,351 bytes, 797 lines download raw bytes

1/**
2 * UDX Docs Concierge -- Chat & Markdown
3 *
4 * Markdown renderer (XSS-safe), chat mode UI, message persistence,
5 * usage tracking, delete messages, cross-bot memory, and explore.
6 * Depends on window.UDXDocs namespace (initialised by concierge.js).
7 */
8window.UDXDocs = window.UDXDocs || {};
9(function (ns) {
10  'use strict';
11
12  var USAGE_KEY = 'udx_docs_usage';
13  var SESSION_KEY = 'udx_docs_session';
14  var FREE_USES = 50;
15
16  var loaderSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>';
17
18  /* ---- HTML escaping (exported for search-result XSS protection) ---- */
19  function escHtml(t) {
20    return (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21  }
22  function escAttr(t) {
23    return escHtml(t || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
24  }
25  ns.escHtml = escHtml;
26  ns.escAttr = escAttr;
27
28  /* ---- Inline formatting ---- */
29  function inlineFmt(t) {
30    var tokens = [];
31    t = t.replace(/`([^`]+)`/g, function (_, c) {
32      tokens.push('<code>' + escHtml(c) + '</code>');
33      return '%%TK_' + (tokens.length - 1) + '%%';
34    });
35    t = t.replace(/\[([^\]]+)\]\(((?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)[\w-]{11}[^)]*)\)/g, function (_, text, href) {
36      tokens.push('<a href="' + escAttr(href) + '" target="_blank" rel="noopener" class="udx-docs-yt-link">\u25B6 ' + escHtml(text) + '</a>');
37      return '%%TK_' + (tokens.length - 1) + '%%';
38    });
39    t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, text, href) {
40      if (/^https?:\/\//i.test(href) || href.charAt(0) === '#' || href.charAt(0) === '/') {
41        tokens.push('<a href="' + escAttr(href) + '" target="_blank" rel="noopener">' + escHtml(text) + '</a>');
42        return '%%TK_' + (tokens.length - 1) + '%%';
43      }
44      return _;
45    });
46    t = t.replace(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})(?:[&?][^\s]*)?/g, function (url) {
47      tokens.push('<a href="' + escAttr(url) + '" target="_blank" rel="noopener" class="udx-docs-yt-link">\u25B6 ' + escHtml(url.replace(/^https?:\/\/(www\.)?/, '').substring(0, 50)) + '</a>');
48      return '%%TK_' + (tokens.length - 1) + '%%';
49    });
50    t = escHtml(t);
51    t = t.replace(/%%TK_(\d+)%%/g, function (_, i) { return tokens[parseInt(i, 10)]; });
52    return t
53      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
54      .replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
55      .replace(/~~(.+?)~~/g, '<del>$1</del>');
56  }
57
58  /* ---- YouTube URL helpers ---- */
59  var ytRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})(?:[&?][^\s]*)?$/;
60
61  function extractYouTubeId(url) {
62    var m = url.trim().match(ytRegex);
63    return m ? m[1] : null;
64  }
65
66  /* ---- SVG sanitisation ---- */
67  var svgAllowedTags = ['svg','g','path','circle','rect','line','polyline','polygon','text','tspan','defs','use','clippath','mask','pattern','lineargradient','radialgradient','stop','marker','title','desc','ellipse'];
68  var svgAllowedAttrs = ['viewbox','width','height','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','stroke-dasharray','stroke-dashoffset','d','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2','points','transform','opacity','font-size','font-family','font-weight','text-anchor','dominant-baseline','id','class','offset','stop-color','stop-opacity','gradientunits','gradienttransform','patternunits','patterntransform','markerwidth','markerheight','refx','refy','orient','markerunits','xmlns','preserveaspectratio','dx','dy','rotate','textlength','lengthadjust','clip-path','clip-rule','fill-opacity','fill-rule','color','display','overflow','visibility'];
69
70  function sanitizeSvg(raw) {
71    var tmp = document.createElement('div');
72    tmp.innerHTML = raw;
73    function walk(node) {
74      var children = Array.prototype.slice.call(node.childNodes);
75      children.forEach(function (child) {
76        if (child.nodeType === 3) return; // text node
77        if (child.nodeType !== 1) { child.remove(); return; }
78        var tag = child.tagName.toLowerCase();
79        if (svgAllowedTags.indexOf(tag) === -1) { child.remove(); return; }
80        var attrs = Array.prototype.slice.call(child.attributes);
81        attrs.forEach(function (a) {
82          var name = a.name.toLowerCase();
83          if (name.indexOf('on') === 0 || svgAllowedAttrs.indexOf(name) === -1) {
84            child.removeAttribute(a.name);
85          }
86        });
87        walk(child);
88      });
89    }
90    walk(tmp);
91    return tmp.innerHTML;
92  }
93
94  /* ---- Mermaid lazy loader ---- */
95  var mermaidLoaded = false;
96  var mermaidReady = null;
97
98  function ensureMermaid() {
99    if (mermaidReady) return mermaidReady;
100    mermaidReady = new Promise(function (resolve) {
101      if (mermaidLoaded && window.mermaid) { resolve(); return; }
102      var sc = document.createElement('script');
103      sc.src = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
104      sc.onload = function () {
105        mermaidLoaded = true;
106        window.mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
107        resolve();
108      };
109      document.head.appendChild(sc);
110    });
111    return mermaidReady;
112  }
113
114  ns.renderMermaid = function (container) {
115    var nodes = container.querySelectorAll('.udx-docs-mermaid[data-mermaid]');
116    if (!nodes.length) return;
117    ensureMermaid().then(function () {
118      nodes.forEach(function (el) {
119        if (el.getAttribute('data-rendered')) return;
120        el.setAttribute('data-rendered', '1');
121        var code = el.getAttribute('data-mermaid');
122        var id = 'mmd-' + Math.random().toString(36).substr(2, 8);
123        try {
124          window.mermaid.render(id, code).then(function (result) {
125            el.innerHTML = result.svg;
126          }).catch(function () {
127            el.textContent = code;
128          });
129        } catch (e) {
130          el.textContent = code;
131        }
132      });
133    });
134  };
135
136  /* ---- Markdown renderer ---- */
137  ns.md = function (s) {
138    if (!s) return '';
139
140    var codeBlocks = [];
141    s = s.replace(/```([\w-]*)\n([\s\S]*?)```/g, function (_, lang, code) {
142      var idx = codeBlocks.length;
143      var langLower = (lang || '').toLowerCase();
144
145      // Mermaid code blocks → render as diagram
146      if (langLower === 'mermaid') {
147        codeBlocks.push('<div class="udx-docs-mermaid" data-mermaid="' + escAttr(code.trim()) + '"></div>');
148        return '\n%%CODEBLOCK_' + idx + '%%\n';
149      }
150
151      // SVG code blocks → sanitize and render inline
152      if (langLower === 'svg') {
153        codeBlocks.push('<div class="udx-docs-svg-render">' + sanitizeSvg(code) + '</div>');
154        return '\n%%CODEBLOCK_' + idx + '%%\n';
155      }
156
157      var cls = lang ? ' class="language-' + lang + '"' : '';
158      var label = lang ? '<span class="code-lang">' + lang + '</span>' : '';
159      codeBlocks.push(label + '<pre data-lang="' + escAttr(lang || '') + '"><code' + cls + '>' + code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</code></pre>');
160      return '\n%%CODEBLOCK_' + idx + '%%\n';
161    });
162
163    // YouTube standalone URLs → embed placeholders
164    var ytEmbeds = [];
165    s = s.replace(/^((?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)[\w-]{11}(?:[&?][^\s]*)?)$/gm, function (match) {
166      var vid = extractYouTubeId(match);
167      if (!vid) return match;
168      var idx = ytEmbeds.length;
169      ytEmbeds.push('<div class="udx-docs-yt-embed"><iframe src="https://www.youtube-nocookie.com/embed/' + vid + '" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe></div>');
170      return '%%YTEMBED_' + idx + '%%';
171    });
172
173    var lines = s.split('\n');
174    var out = [];
175    var i = 0;
176
177    while (i < lines.length) {
178      var line = lines[i];
179
180      var cbMatch = line.match(/^%%CODEBLOCK_(\d+)%%$/);
181      if (cbMatch) {
182        out.push(codeBlocks[parseInt(cbMatch[1], 10)]);
183        i++;
184        continue;
185      }
186
187      var ytMatch = line.match(/^%%YTEMBED_(\d+)%%$/);
188      if (ytMatch) {
189        out.push(ytEmbeds[parseInt(ytMatch[1], 10)]);
190        i++;
191        continue;
192      }
193
194      if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trim())) {
195        out.push('<hr>');
196        i++;
197        continue;
198      }
199
200      var headMatch = line.match(/^(#{1,6})\s+(.+)$/);
201      if (headMatch) {
202        var lvl = headMatch[1].length;
203        out.push('<h' + lvl + '>' + inlineFmt(headMatch[2]) + '</h' + lvl + '>');
204        i++;
205        continue;
206      }
207
208      if (/^>\s?/.test(line)) {
209        var bqLines = [];
210        while (i < lines.length && /^>\s?/.test(lines[i])) {
211          bqLines.push(lines[i].replace(/^>\s?/, ''));
212          i++;
213        }
214        out.push('<blockquote>' + inlineFmt(bqLines.join('<br>')) + '</blockquote>');
215        continue;
216      }
217
218      if (line.indexOf('|') !== -1 && i + 1 < lines.length && /^\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])
218) {
219        var headers = line.split('|').map(function (c) { return c.trim(); }).filter(Boolean);
220        var alignRow = lines[i + 1].split('|').map(function (c) { return c.trim(); }).filter(Boolean);
221        var aligns = alignRow.map(function (c) {
222          if (/^:-+:$/.test(c)) return 'center';
223          if (/^-+:$/.test(c)) return 'right';
224          return 'left';
225        });
226        var thtml = '<div class="md-table-wrap"><table><thead><tr>';
227        for (var h = 0; h < headers.length; h++) {
228          thtml += '<th style="text-align:' + (aligns[h] || 'left') + '">' + inlineFmt(headers[h]) + '</th>';
229        }
230        thtml += '</tr></thead><tbody>';
231        i += 2;
232        while (i < lines.length && lines[i].indexOf('|') !== -1) {
233          var cells = lines[i].split('|').map(function (c) { return c.trim(); }).filter(Boolean);
234          thtml += '<tr>';
235          for (var c = 0; c < headers.length; c++) {
236            thtml += '<td style="text-align:' + (aligns[c] || 'left') + '">' + inlineFmt(cells[c] || '') + '</td>';
237          }
238          thtml += '</tr>';
239          i++;
240        }
241        thtml += '</tbody></table></div>';
242        out.push(thtml);
243        continue;
244      }
245
246      if (/^[\s]*[-*+]\s+/.test(line)) {
247        var items = [];
248        while (i < lines.length && /^[\s]*[-*+]\s+/.test(lines[i])) {
249          items.push(inlineFmt(lines[i].replace(/^[\s]*[-*+]\s+/, '')));
250          i++;
251        }
252        out.push('<ul>' + items.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</ul>');
253        continue;
254      }
255
256      if (/^[\s]*\d+[.)]\s+/.test(line)) {
257        var oItems = [];
258        while (i < lines.length && /^[\s]*\d+[.)]\s+/.test(lines[i])) {
259          oItems.push(inlineFmt(lines[i].replace(/^[\s]*\d+[.)]\s+/, '')));
260          i++;
261        }
262        out.push('<ol>' + oItems.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</ol>');
263        continue;
264      }
265
266      if (line.trim() === '') {
267        i++;
268        continue;
269      }
270
271      var pLines = [];
272      while (i < lines.length && lines[i].trim() !== '' && !/^#{1,6}\s/.test(lines[i]) && !/^[-*+]\s+/.test(lines[i]) && !/^\d+[.)]\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i].trim()) && !/^%%CODEBLOCK_/.test(lines[i]) && !/^%%YTEMBED_/.test(lines[i]) && !(lines[i].indexOf('|') !== -1 && i + 1 < lines.length && /^\|?\s*[-:]+[-| :]*$/.test(lines[i + 1] || ''))) {
273        pLines.push(lines[i]);
274        i++;
275      }
276      if (pLines.length) {
277        out.push('<p>' + inlineFmt(pLines.join('<br>')) + '</p>');
278      }
279    }
280
281    return out.join('');
282  };
283
284  /* ---- Copy buttons on code blocks ---- */
285  ns.addCopyButtons = function (container) {
286    var blocks = container.querySelectorAll('pre');
287    blocks.forEach(function (pre) {
288      if (pre.querySelector('.udx-docs-copy-btn')) return;
289      var btn = document.createElement('button');
290      btn.className = 'udx-docs-copy-btn';
291      btn.textContent = 'Copy';
292      btn.addEventListener('click', function () {
293        var code = pre.querySelector('code');
294        var text = code ? code.textContent : pre.textContent;
295        navigator.clipboard.writeText(text).then(function () {
296          btn.textContent = 'Copied';
297          btn.classList.add('copied');
298          setTimeout(function () {
299            btn.textContent = 'Copy';
300            btn.classList.remove('copied');
301          }, 2000);
302        });
303      });
304      pre.appendChild(btn);
305    });
306  };
307
308  /* ---- Usage tracking ---- */
309  ns.getUsageCount = function () {
310    try { return parseInt(localStorage.getItem(USAGE_KEY) || '0', 10); } catch (e) { return 0; }
311  };
312  ns.incrementUsage = function () {
313    try { localStorage.setItem(USAGE_KEY, String(ns.getUsageCount() + 1)); } catch (e) { /* noop */ }
314  };
315  ns.isOverLimit = function () {
316    return ns.getUsageCount() >= FREE_USES;
317  };
318  ns.renderUsageGate = function (container) {
319    var remaining = FREE_USES - ns.getUsageCount();
320    if (remaining > 10) return;
321    var wrap = container.querySelector('.udx-docs-usage-note');
322    if (wrap) wrap.remove();
323    var el = document.createElement('div');
324    el.className = 'udx-docs-usage-note';
325    if (remaining <= 0) {
326      el.innerHTML = '<div style="text-align:center;padding:24px 16px;color:#19234c;font-size:0.85rem;line-height:1.5;">' +
327        '<p style="font-weight:500;margin:0 0 8px 0;">You\'ve used your free sessions</p>' +
328        '<p style="color:#5d5f68;margin:0 0 16px 0;font-size:0.78rem;">Get unlimited access and direct engineering support.</p>' +
329        '<a href="/contact" style="display:inline-block;padding:10px 24px;background:#204aff;color:#fff;border-radius:8px;text-decoration:none;font-size:0.85rem;font-weight:500;">Talk to us</a>' +
330        '</div>';
331    } else if (remaining <= 10) {
332      el.innerHTML = '<div style="text-align:center;padding:8px;color:#5d5f68;font-size:0.72rem;">
332' + remaining + ' free questions remaining</div>';
333    }
334    container.appendChild(el);
335  };
336
337  /* ---- Session persistence (no expiry -- lives as long as browser allows) ---- */
338  ns.saveSession = function () {
339    try {
340      var data = {
341        messages: ns.state.messages,
342        activeTopic: ns.state.activeTopic,
343        exploredContent: ns.state.exploredContent,
344        mode: ns.state.mode,
345        ts: Date.now(),
346      };
347      localStorage.setItem(SESSION_KEY, JSON.stringify(data));
348    } catch (e) { /* noop */ }
349  };
350  ns.loadSession = function () {
351    try {
352      var raw = localStorage.getItem(SESSION_KEY);
353      if (!raw) return null;
354      return JSON.parse(raw);
355    } catch (e) { return null; }
356  };
357  ns.clearSession = function () {
358    try { localStorage.removeItem(SESSION_KEY); } catch (e) { /* noop */ }
359  };
360
361  /* ---- Cross-bot memory: read homepage chatbot history (PII-stripped) ---- */
362  function stripPii(text) {
363    return text
364      .replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, '[email]')
365      .replace(/(\+?1?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})/g, '[phone]')
366      .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted]');
367  }
368  ns.getCrossBotContext = function () {
369    try {
370      var keys = ['udx_concierge_v2', 'udx_chat_history', 'udx_chat_messages'];
371      for (var i = 0; i < keys.length; i++) {
372        var raw = localStorage.getItem(keys[i]);
373        if (!raw) continue;
374        var data = JSON.parse(raw);
375        var msgs = Array.isArray(data) ? data : (data.messages || data.history || []);
376        if (!msgs.length) continue;
377        var recent = msgs.slice(-10);
378        var summary = recent.map(function (m) {
379          var role = m.role || (m.isBot ? 'assistant' : 'user');
380          var text = stripPii((m.content || m.text || m.message || '').substring(0, 150));
381          return role + ': ' + text;
382        }).join('\n');
383        return summary;
384      }
385    } catch (e) { /* noop */ }
386    return '';
387  };
388
389  /* ---- Chat mode ---- */
390  ns.switchToChat = function (initialQuery, topic, restoring) {
391    ns.state.mode = 'chat';
392    if (topic) ns.state.activeTopic = topic;
393
394    var header = ns.panel.querySelector('.udx-docs-header');
395    if (header) header.style.display = 'none';
396
397    var body = ns.panel.querySelector('.udx-docs-body');
398    body.innerHTML = '';
399
400    var chat = document.createElement('div');
401    chat.className = 'udx-docs-chat';
402
403    var topicBadge = '';
404    var at = ns.state.activeTopic;
405    if (at && at.label) {
406      topicBadge = '<div class="udx-docs-topic-badge"><span class="badge-icon">' +
407        ns.getTopicIcon(at.id) + '</span>' + escHtml(at.label) + '</div>';
408    }
409
410    chat.innerHTML = [
411      topicBadge,
412      '<div class="udx-docs-chat-msgs"></div>',
413      '<div class="udx-docs-chat-input-wrap">',
414      '  <input class="udx-docs-chat-input" type="text" placeholder="Ask about ' +
415        escAttr(at && at.label ? at.label.toLowerCase() : 'UDX tools, automation, architecture') +
416        '..." autocomplete="off" />',
417      '  <button class="udx-docs-chat-send">Send</button>',
418      '</div>',
419    ].join('\n');
420    body.appendChild(chat);
421
422    var input = chat.querySelector('.udx-docs-chat-input');
423    var sendBtn = chat.querySelector('.udx-docs-chat-send');
424
425    function send() {
426      var text = input.value.trim();
427      if (!text || ns.state.asking) return;
428
429      if (text.toLowerCase() === 'clear') {
430        input.value = '';
431        ns.state.messages = [];
432        ns.state.activeTopic = null;
433        ns.state.exploredContent = [];
434        ns.state.mode = 'search';
435        ns.clearSession();
436        if (header) header.style.display = '';
437        ns.renderHome();
438        return;
439      }
440
441      input.value = '';
442      ns.sendMessage(text);
443    }
444
445    sendBtn.addEventListener('click', send);
446    input.addEventListener('keydown', function (e) {
447      if (e.key === 'Enter') { e.preventDefault(); send(); }
448    });
449
450    if (restoring) {
451      ns.renderMessages();
452    } else if (initialQuery) {
453      ns.sendMessage(initialQuery);
454    } else if (topic) {
455      var greeting = "I can help you with **" + topic.label + "**";
456      if (topic.description) greeting += " — " + topic.description;
457      if (topic.explore && topic.explore.length > 0) {
458        greeting += "\n\nExplore the resources below for deeper context, or just ask me a question.";
459      } else {
460        greeting += "\n\nWhat would you like to know?";
461      }
462      ns.addMessage(greeting, 'assistant');
463      if (topic.explore && topic.explore.length > 0) {
464        ns.renderExploreSuggestions(topic.explore);
465      }
466      input.focus();
467    } else {
468      input.focus();
469    }
470
471    ns.saveSession();
472  };
473
474  /* ---- Send a message to the AI ---- */
475  ns.sendMessage = function (text) {
476    if (ns.isOverLimit()) {
477      var msgs = ns.panel.querySelector('.udx-docs-chat-msgs');
478      if (msgs) ns.renderUsageGate(msgs);
479      return;
480    }
481    ns.incrementUsage();
482
483    ns.state.messages.push({ role: 'user', content: text });
484    ns.renderMessages();
485    ns.state.asking = true;
486
487    var msgs = ns.panel.querySelector('.udx-docs-chat-msgs');
488    var typing = document.createElement('div');
489    typing.className = 'udx-docs-retrieval';
490    typing.innerHTML = '<span class="spin">' + loaderSvg + '</span><span>Thinking...</span>';
491    var typingPhases = ['Thinking...', 'Retrieving documentation...', 'Analyzing results...'];
492    var phase = 0;
493    var phaseTimer = setInterval(function () {
494      phase++;
495      if (phase < typingPhases.length) {
496        var span = typing.querySelector('span:last-child');
497        if (span) span.textContent = typingPhases[phase];
498      }
499    }, 3000);
500    msgs.appendChild(typing);
501    msgs.scrollTop = msgs.scrollHeight;
502
503    var sendBtn = ns.panel.querySelector('.udx-docs-chat-send');
504    if (sendBtn) sendBtn.disabled = true;
505
506    var crossBot = (ns.state.messages.length <= 1) ? ns.getCrossBotContext() : '';
507    var prompt = crossBot
508      ? text + '\n\n[Context from previous conversation on this site:]\n' + crossBot
509      : text;
510
511    var payload = {
512      prompt: prompt,
513      search_query: text,
514      messages: ns.state.messages.map(function (m) { return { role: m.role, content: m.content }; }),
515    };
516
517    if (ns.state.activeTopic) {
518      payload.topic = ns.state.activeTopic.id;
519    }
520    if (ns.state.exploredContent.length) {
521      payload.explored_content = ns.state.exploredContent;
522    }
523
524    var attempt = 0;
525    var maxRetries = 1;
526
527    function doFetch() {
528      return fetch(ns.askUrl, {
529        method: 'POST',
530        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': ns.nonce },
531        body: JSON.stringify(payload),
532      })
533      .then(function (r) {
534        if (r.status >= 500 && attempt < maxRetries) {
535          attempt++;
536          var span = typing.querySelector('span:last-child');
537          if (span) span.textContent = 'Retrying...';
538          return new Promise(function (resolve) {
539            setTimeout(resolve, 2000 * attempt);
540          }).then(doFetch);
541        }
542        if (r.status === 429) {
543          return { ok: false, message: 'Too many requests. Please wait a moment and try again.', rate_limited: true };
544        }
545        return r.json();
546      })
547      .then(function (data) {
548        if (data && data.rate_limited) return data;
549        return data;
550      });
551    }
552
553    doFetch()
554    .then(function (data) {
555      ns.state.asking = false;
556      clearInterval(phaseTimer);
557      if (sendBtn) sendBtn.disabled = false;
558      if (typing.parentNode) typing.remove();
559
560      var reply = (data.ok ? data.response : (data.message || 'Something went wrong.')) || '';
561
562      if (data.topic && !ns.state.activeTopic) {
563        var found = (ns.state.topics || []).find(function (t) { return t.id === data.topic; });
564        ns.state.activeTopic = found || { id: data.topic, label: data.topic };
565      }
566
567      if (data.fetched_urls && data.fetched_urls.length) {
568        reply += '\n\n*Sources consulted: ' + data.fetched_urls.map(function (u) {
569          return '[' + u.replace(/^https?:\/\/(www\.)?/, '').substring(0, 50) + '](' + u + ')';
570        }).join(', ') + '*';
571      }
572      ns.state.messages.push({ role: 'assistant', content: reply });
573      ns.renderMessages();
574
575      if (data.suggest_explore && data.suggest_explore.length) {
576        ns.renderExploreSuggestions(data.suggest_explore);
577      }
578
579      ns.saveSession();
580      ns.renderUsageGate(msgs);
581
582      var chatInput = ns.panel.querySelector('.udx-docs-chat-input');
583      if (chatInput) chatInput.focus();
584    })
585    .catch(function (err) {
586      if (attempt < maxRetries) {
587        attempt++;
588        var span = typing.querySelector('span:last-child');
589        if (span) span.textContent = 'Retrying...';
590        setTimeout(function () {
591          doFetch().then(function (data) {
592            ns.state.asking = false;
593            clearInterval(phaseTimer);
594            if (sendBtn) sendBtn.disabled = false;
595            if (typing.parentNode) typing.remove();
596            var reply = (data && data.ok ? data.response : (data && data.message || 'Something went wrong.')) || '';
597            if (data && data.topic && !ns.state.activeTopic) {
598              var found = (ns.state.topics || []).find(function (t) { return t.id === data.topic; });
599              ns.state.activeTopic = found || { id: data.topic, label: data.topic };
600            }
601            if (data && data.fetched_urls && data.fetched_urls.length) {
602              reply += '\n\n*Sources consulted: ' + data.fetched_urls.map(function (u) {
603                return '[' + u.replace(/^https?:\/\/(www\.)?/, '').substring(0, 50) + '](' + u + ')';
604              }).join(', ') + '*';
605            }
606            ns.state.messages.push({ role: 'assistant', content: reply });
607            ns.renderMessages();
608            ns.saveSession();
609          }).catch(function () {
610            ns.state.asking = false;
611            clearInterval(phaseTimer);
612            if (sendBtn) sendBtn.disabled = false;
613            if (typing.parentNode) typing.remove();
614            ns.state.messages.push({ role: 'assistant', content: 'Service temporarily unavailable. Please try again in a moment.' });
615            ns.renderMessages();
616          });
617        }, 3000);
618        return;
619      }
620      ns.state.asking = false;
621      clearInterval(phaseTimer);
622      if (sendBtn) sendBtn.disabled = false;
623      if (typing.parentNode) typing.remove();
624      var errMsg = err && err.message && err.message.indexOf('timeout') !== -1
625        ? 'Request timed out. The AI is processing a complex query. Please try again.'
626        : 'Service temporarily unavailable. Please try again in a moment.';
627      ns.state.messages.push({ role: 'assistant', content: errMsg });
628      ns.renderMessages();
629    });
630  };
631
632  /* ---- Revert to topic picker when chat is empty ---- */
633  ns.revertToTopics = function () {
634    ns.state.activeTopic = null;
635    ns.state.exploredContent = [];
636    ns.state.mode = 'search';
637    ns.clearSession();
638    var header = ns.panel.querySelector('.udx-docs-header');
639    if (header) header.style.display = '';
640    ns.renderHome();
641  };
642
643  /* ---- Delete individual message (with 7s undo) ---- */
644  ns.deleteMsg = function (msg, msgEl) {
645    var idx = ns.state.messages.indexOf(msg);
646    if (idx < 0) return;
647
648    ns.state.messages.splice(idx, 1);
649    ns.saveSession();
650
651    if (!msgEl) {
652      if (!ns.state.messages.length) { ns.revertToTopics(); return; }
653      ns.renderMessages();
654      return;
655    }
656
657    var undo = document.createElement('div');
658    undo.className = 'udx-docs-undo';
659    undo.innerHTML = 'Message deleted. <button class="udx-docs-undo-btn">Undo</button>';
660    msgEl.parentNode.replaceChild(undo, msgEl);
661
662    var timer = setTimeout(function () {
663      if (undo.parentNode) {
664        undo.style.opacity = '0';
665        setTimeout(function () {
666          if (undo.parentNode) undo.parentNode.removeChild(undo);
667          if (!ns.state.messages.length) ns.revertToTopics();
668        }, 300);
669      }
670    }, 7000);
671
672    undo.querySelector('.udx-docs-undo-btn').addEventListener('click', function () {
673      clearTimeout(timer);
674      ns.state.messages.splice(idx, 0, msg);
675      ns.saveSession();
676      ns.renderMessages();
677    });
678  };
679
680  /* ---- Render all messages ---- */
681  ns.renderMessages = function () {
682    var msgs = ns.panel.querySelector('.udx-docs-chat-msgs');
683    if (!msgs) return;
684
685    msgs.innerHTML = '';
686
687    ns.state.messages.forEach(function (m) {
688      var div = document.createElement('div');
689      div.className = 'udx-docs-msg udx-docs-msg-' + (m.role === 'user' ? 'user' : 'bot');
690      var del = document.createElement('button');
691      del.className = 'udx-docs-msg-del';
692      del.title = 'Delete';
693      del.innerHTML = '&times;';
694      del.addEventListener('click', function (e) { e.stopPropagation(); ns.deleteMsg(m, div); });
695      div.appendChild(del);
696      var bubble = document.createElement('div');
697      bubble.className = 'msg-bubble';
698      if (m.role === 'user') {
699        bubble.textContent = m.content;
700      } else {
701        bubble.innerHTML = ns.md(m.content);
702      }
703      div.appendChild(bubble);
704      msgs.appendChild(div);
705    });
706
707    msgs.scrollTop = msgs.scrollHeight;
708    ns.addCopyButtons(msgs);
709    ns.renderMermaid(msgs);
710  };
711
712  /* ---- Add a single message to the chat ---- */
713  ns.addMessage = function (text, role) {
714    ns.state.messages.push({ role: role || 'assistant', content: text });
715    ns.renderMessages();
716    ns.saveSession();
717  };
718
719  /* ---- Explore: fetch page content for deeper context ---- */
720  ns.renderExploreSuggestions = function (urls) {
721    var msgs = ns.panel.querySelector('.udx-docs-chat-msgs');
722    if (!msgs) return;
723
724    var wrap = document.createElement('div');
725    wrap.className = 'udx-docs-explore-wrap';
726    wrap.innerHTML = '<p>Explore related docs for deeper context:</p>';
727
728    urls.forEach(function (url) {
729      var alreadyExplored = ns.state.exploredContent.some(function (ec) { return ec.url === url; });
730      if (alreadyExplored) return;
731
732      var btn = document.createElement('button');
733      btn.className = 'udx-docs-explore-btn';
734      btn.textContent = url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
735      btn.addEventListener('click', function () {
736        ns.exploreUrl(url, btn);
737      });
738      wrap.appendChild(btn);
739    });
740
741    if (wrap.querySelectorAll('.udx-docs-explore-btn').length > 0) {
742      msgs.appendChild(wrap);
743      msgs.scrollTop = msgs.scrollHeight;
744    }
745  };
746
747  ns.exploreUrl = function (url, btn) {
748    if (btn) {
749      btn.classList.add('loading');
750      btn.textContent = 'Loading...';
751    }
752
753    fetch(ns.exploreEndpoint, {
754      method: 'POST',
755      headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': ns.nonce },
756      body: JSON.stringify({ url: url }),
757    })
758    .then(function (r) { return r.json(); })
759    .then(function (data) {
760      if (data.ok && data.content) {
761        ns.state.exploredContent.push({ url: data.url, content: data.content });
762        if (btn) {
763          btn.textContent = '\u2713 Loaded';
764          btn.style.color = '#2a7d4f';
765          btn.style.cursor = 'default';
766          btn.classList.remove('loading');
767          btn.disabled = true;
768        }
769        ns.saveSession();
770
771        var shortUrl = url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
772        if (shortUrl.length > 40) shortUrl = shortUrl.substring(0, 40) + '...';
773        var input = ns.panel.querySelector('.udx-docs-chat-input');
774        if (input && !ns.state.asking) {
775          input.placeholder = 'Ask about ' + shortUrl;
776          input.focus();
777        }
778        ns.addMessage("Loaded content from **" + shortUrl + "**. You can now ask questions about it.", 'assistant');
779        if (ns.state.activeTopic && ns.state.activeTopic.explore) {
780          ns.renderExploreSuggestions(ns.state.activeTopic.explore);
781        }
782      } else {
783        if (btn) {
784          btn.textContent = 'Failed';
785          btn.classList.remove('loading');
786        }
787      }
788    })
789    .catch(function () {
790      if (btn) {
791        btn.textContent = 'Error';
792        btn.classList.remove('loading');
793      }
794    });
795  };
796
797})(window.UDXDocs);

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.