1/* Cliverse AI Search â overlay controller */ 2( function ( $ ) { 3 'use strict'; 4 5 var cfg = window.cliverseAISearch || {}; 6 var RECENT_KEY = 'cliverse_ai_recent_searches'; 7 var RECENT_MAX = 6; 8 9 var $overlay, $views, lastQuery = '', lastArticles = [], chatThreadId = '', chatSeeded = false, chatSession = 0; 10 var curPage = 1, totalPages = 0, pageReq = 0; 11 12 /* ---------- helpers ---------- */ 13 function esc( s ) { 14 return $( '<div/>' ).text( s == null ? '' : s ).html(); 15 } 16 17 function getRecent() { 18 try { return JSON.parse( localStorage.getItem( RECENT_KEY ) ) || []; } 19 catch ( e ) { return []; } 20 } 21 function pushRecent( q ) { 22 q = $.trim( q ); 23 if ( ! q ) { return; } 24 var list = getRecent().filter( function ( x ) { return x.toLowerCase() !== q.toLowerCase(); } ); 25 list.unshift( q ); 26 list = list.slice( 0, RECENT_MAX ); 27 try { localStorage.setItem( RECENT_KEY, JSON.stringify( list ) ); } catch ( e ) {} 28 } 29 30 /* ---------- view switching ---------- */ 31 function showView( name ) { 32 $views.removeClass( 'is-active' ); 33 $overlay.find( '.cas-view--' + name ).addClass( 'is-active' ); 34 } 35 36 var lockedScrollY = 0; 37 38 // Fully lock the background page so only the overlay scrolls. body{overflow: 39 // hidden} alone doesn't hold â this theme scrolls on <html>, and iOS ignores 40 // it â so we pin the body with position:fixed (CSS) at its current offset and 41 // restore the scroll position on unlock. 42 function lockBackground() { 43 if ( $( 'body' ).hasClass( 'cas-locked' ) ) { return; } 44 lockedScrollY = window.pageYOffset || document.documentElement.scrollTop || 0; 45 document.body.style.top = ( -lockedScrollY ) + 'px'; 46 $( 'body' ).addClass( 'cas-locked' ); 47 } 48 function unlockBackground() { 49 if ( ! $( 'body' ).hasClass( 'cas-locked' ) ) { return; } 50 $( 'body' ).removeClass( 'cas-locked' ); 51 document.body.style.top = ''; 52 window.scrollTo( 0, lockedScrollY ); 53 } 54 55 function openOverlay() { 56 $overlay.addClass( 'is-open' ).attr( 'aria-hidden', 'false' ); 57 lockBackground(); 58 // Each fresh open starts a clean session â otherwise a previous chat 59 // (messages + n8n thread) lingers hidden and resurfaces on the next 60 // "Continue chatting", ignoring the new query. 61 resetChat(); 62 showView( 'hero' ); 63 setTimeout( function () { $overlay.find( '.cas-view--hero .cas-input' ).trigger( 'focus' ); }, 50 ); 64 } 65 function closeOverlay() { 66 $overlay.removeClass( 'is-open' ).attr( 'aria-hidden', 'true' ); 67 unlockBackground(); 68 } 69 70 /* ---------- rendering: results ---------- */ 71 function renderRecent() { 72 var $ul = $overlay.find( '.cas-recent' ).empty(); 73 var list = getRecent(); 74 if ( ! list.length ) { 75 $ul.append( '<li class="cas-empty">No recent searches yet.</li>' ); 76 return; 77 } 78 list.forEach( function ( q ) { 79 var parts = q.split( ' ' ); 80 var first = esc( parts.shift() ); 81 var rest = parts.length ? ' ' + esc( parts.join( ' ' ) ) : ''; 82 $( '<li/>' ).html( '<b>' + first + '</b>' + rest ).data( 'q', q ).appendTo( $ul ); 83 } ); 84 } 85 86 function renderArticles( articles ) { 87 var $wrap = $overlay.find( '.cas-articles' ).empty(); 88 if ( ! articles.length ) { 89 $wrap.append( '<div class="cas-empty">' + esc( $wrap.attr( 'data-empty' ) ) + '</div>' ); 90 return; 91 } 92 articles.forEach( function ( a ) { 93 var thumb = a.thumb ? '<img class="cas-article-thumb" src="' + esc( a.thumb ) + '" alt="" loading="lazy">' 94 : '<span class="cas-article-thumb"></span>'; 95 var author = a.author 96 ? '<div class="cas-article-author">' + 97 ( a.avatar ? '<img src="' + esc( a.avatar ) + '" alt="">' : '' ) + 98 '<span>by ' + esc( a.author ) + '</span></div>' 99 : ''; 100 $( '<a class="cas-article" target="_blank" rel="noopener"></a>' ) 101 .attr( 'href', a.url ) 102 .html( 103 thumb + 104 '<div class="cas-article-body">' + 105 ( a.category ? '<p class="cas-article-cat">' + esc( a.category ) + '</p>' : '' ) + 106 '<h4 class="cas-article-title">' + esc( a.title ) + '</h4>' + 107 author + 108 '</div>' 109 ) 110 .appendTo( $wrap ); 111 } ); 112 } 113 114 /* Numbered pagination for the Related Articles list (Google-style: Prev, 115 * a windowed set of page numbers with ellipses, Next). Hidden for a single 116 * page. Buttons carry data-page; the delegated handler loads that page. */ 117 function renderPagination( page, pages ) {
118 var $nav = $overlay.find( '.cas-pagination' ).empty(); 119 if ( !pages || pages < 2 ) { return; } 120 121 function btn( label, target, opts ) { 122 opts = opts || {}; 123 var $b = $( '<button type="button" class="cas-page"></button>' ).html( label ); 124 if ( opts.current ) { $b.addClass( 'is-current' ).attr( 'aria-current', 'page' ); } 125 if ( opts.disabled ) { $b.prop( 'disabled', true ).addClass( 'is-disabled' ); } 126 else { $b.attr( 'data-page', target ); } 127 $nav.append( $b ); 128 } 129 function gap() { $nav.append( '<span class="cas-page-gap">â¦</span>' ); } 130 131 btn( 'â¹', page - 1, { disabled: page <= 1 } ); 132 133 // Window of page numbers around the current page, always showing first/last. 134 var win = 1, list = []; 135 for ( var i = 1; i <= pages; i++ ) { 136 if ( i === 1 || i === pages || ( i >= page - win && i <= page + win ) ) { list.push( i ); } 137 } 138 var prev = 0; 139 list.forEach( function ( n ) { 140 if ( prev && n - prev > 1 ) { gap(); } 141 btn( String( n ), n, { current: n === page } ); 142 prev = n; 143 } ); 144 145 btn( 'âº', page + 1, { disabled: page >= pages } ); 146 } 147 148 /* Load a specific page of Related Articles for the current query. Page 1 is 149 * the primary render (owns lastArticles for chat seeding, tools, discussions); 150 * pages > 1 refresh only the article list + pagination and scroll to the top. 151 * A pageReq token discards stale responses when the user clicks quickly. */ 152 function loadArticlesPage( q, page ) { 153 var token = ++pageReq; 154 $overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Searchingâ¦</div>' ); 155 $overlay.find( '.cas-pagination' ).empty(); 156 157 $.ajax( { 158 url: cfg.searchUrl, 159 method: 'GET', 160 data: { q: q, page: page }, 161 headers: { 'X-WP-Nonce': cfg.nonce } 162 } ).done( function ( res ) { 163 if ( token !== pageReq || q !== lastQuery ) { return; } // superseded 164 curPage = res.page || page; 165 totalPages = res.pages || 0; 166 var articles = res.articles || []; 167 if ( curPage === 1 ) { lastArticles = articles; } 168 renderArticles( articles ); 169 renderPagination( curPage, totalPages ); 170 if ( page > 1 ) { 171 var ov = $overlay[ 0 ]; 172 if ( ov ) { ov.scrollTop = 0; } 173 } 174 } ).fail( function () { 175 if ( token !== pageReq ) { return; } 176 $overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Something went wrong. Please try again.</div>' ); 177 } ); 178 } 179 180 function renderTools( tools ) { 181 var $block = $overlay.find( '.cas-tools-block' ); 182 var $wrap = $overlay.find( '.cas-tools' ).empty(); 183 if ( ! tools.length ) { $block.hide(); return; } 184 $block.show(); 185 var fallback = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M21 5h-7l-2-2H3v16h18V5zM3 19V7h6.2l2 2H21" fill="none" stroke="currentColor" stroke-width="2"/></svg>'; 186 tools.forEach( function ( t ) { 187 var icon = t.icon 188 ? '<span class="cas-tool-icon cas-tool-icon--img"><img src="' + esc( t.icon ) + '" alt="" loading="lazy"></span>' 189 : '<span class="cas-tool-icon">' + fallback + '</span>'; 190 $( '<a class="cas-tool" target="_blank" rel="noopener"></a>' ) 191 .attr( 'href', t.url ) 192 .html( icon + '<span>' + esc( t.label ) + '</span>' ) 193 .appendTo( $wrap ); 194 } ); 195 } 196 197 function renderDiscussions( items ) { 198 var $block = $overlay.find( '.cas-discussions-block' ); 199 var $wrap = $overlay.find( '.cas-discussions' ).empty(); 200 if ( ! items.length ) { $block.hide(); return; } 201 $block.show(); 202 items.forEach( function ( d ) { 203 var meta = '<div class="cas-discussion-meta"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 4h16v12H8l-4 4V4z" fill="currentColor"/></svg><span>' + ( d.posts || 0 ) + '</span></div>'; 204 $( '<a class="cas-discussion" target="_blank" rel="noopener"></a>' ) 205 .attr( 'href', d.url || '#' ) 206 .html( '<div class="cas-discussion-title">' + esc( d.title ) + '</div>' + meta ) 207 .appendTo( $wrap ); 208 } ); 209 } 210 211 /* Toggle the clear (Ã) button visibility for a search field. */ 212 function syncClear( $input ) { 213 $input.closest( '.cas-searchbar' ) 214 .find( '.cas-clear' ) 215 .toggleClass( 'is-visible', $.trim( $input.val() ) !== '' ); 216 } 217 218 /* Reset the chat session (messages, n8n thread, seeded flag) so the next 219 * "Continue chatting" starts fresh for the current query. */ 220 function resetChat() { 221 chatSeeded = false;
222 chatThreadId = ''; 223 chatSession++; // invalidate any in-flight chat request from the old session 224 $overlay.find( '.cas-messages' ).empty(); 225 } 226 227 /* ---------- search ---------- */ 228 function runSearch( q ) { 229 q = $.trim( q ); 230 if ( ! q ) { return; } 231 // A new topic invalidates any chat seeded for the previous query. 232 if ( q !== lastQuery ) { resetChat(); } 233 lastQuery = q; 234 pushRecent( q ); 235 236 showView( 'results' ); 237 $overlay.find( '.cas-view--results .cas-input' ).val( q ); 238 syncClear( $overlay.find( '.cas-view--results .cas-input' ) ); 239 renderRecent(); 240 curPage = 1; totalPages = 0; 241 var token = ++pageReq; 242 $overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Searchingâ¦</div>' ); 243 $overlay.find( '.cas-pagination' ).empty(); 244 $overlay.find( '.cas-tools, .cas-discussions' ).empty(); 245 246 $.ajax( { 247 url: cfg.searchUrl, 248 method: 'GET', 249 data: { q: q, page: 1 }, 250 headers: { 'X-WP-Nonce': cfg.nonce } 251 } ).done( function ( res ) { 252 if ( token !== pageReq ) { return; } 253 curPage = res.page || 1; 254 totalPages = res.pages || 0; 255 lastArticles = res.articles || []; 256 renderArticles( lastArticles ); 257 renderPagination( curPage, totalPages ); 258 renderTools( res.tools || [] ); 259 renderDiscussions( res.discussions || [] ); 260 } ).fail( function () { 261 if ( token !== pageReq ) { return; } 262 $overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Something went wrong. Please try again.</div>' ); 263 } ); 264 } 265 266 /* ---------- chat ---------- */ 267 function appendMsg( who, html, extraClass ) { 268 var $msg = $( '<div class="cas-msg"></div>' ) 269 .addClass( 'cas-msg--' + who ) 270 .addClass( extraClass || '' ) 271 .html( '<span class="cas-msg-avatar"></span><div class="cas-msg-bubble">' + html + '</div>' ); 272 $overlay.find( '.cas-messages' ).append( $msg ); 273 var scroll = $overlay.find( '.cas-chat-scroll' )[ 0 ]; 274 if ( scroll ) { scroll.scrollTop = scroll.scrollHeight; } 275 return $msg; 276 } 277 278 // The assistant returns Markdown-ish text (### headings, blank-line 279 // paragraphs, - / 1. lists, **bold**) with inline <a> links already 280 // sanitised server-side (wp_kses_post). Render the block structure to HTML 281 // so long answers read as paragraphs/lists instead of one wall of text; 282 // existing <a> tags are passed through untouched. 283 function formatAnswer( html ) { 284 if ( ! html ) { return ''; } 285 286 var inline = function ( s ) { 287 return s 288 .replace( /\*\*([^*]+)\*\*/g, '<strong>$1</strong>' ) // **bold** 289 .replace( /\n/g, '<br>' ); // soft line break 290 }; 291 292 var text = String( html ).replace( /\r\n/g, '\n' ).trim(); 293 var blocks = text.split( /\n{2,}/ ); // blank line = paragraph boundary 294 var out = ''; 295 296 blocks.forEach( function ( block ) { 297 block = block.replace( /^\n+|\n+$/g, '' ); 298 if ( ! block ) { return; } 299 var lines = block.split( '\n' ); 300 301 // Whole block is a bullet / numbered list. 302 var nonEmpty = lines.filter( function ( l ) { return l.trim() !== ''; } ); 303 var allBullets = nonEmpty.length && nonEmpty.every( function ( l ) { return /^\s*[-*]\s+/.test( l ); } ); 304 var allNumbers = nonEmpty.length && nonEmpty.every( function ( l ) { return /^\s*\d+[.)]\s+/.test( l ); } ); 305 if ( allBullets || allNumbers ) { 306 var tag = allNumbers ? 'ol' : 'ul'; 307 out += '<' + tag + ' class="cas-msg-list">' + nonEmpty.map( function ( l ) { 308 return '<li>' + inline( l.replace( /^\s*(?:[-*]|\d+[.)])\s+/, '' ) ) + '</li>'; 309 } ).join( '' ) + '</' + tag + '>'; 310 return; 311 } 312 313 // Heading line (### Title), optionally followed by body text. 314 var hm = /^#{1,6}\s+(.*)$/.exec( lines[ 0 ] ); 315 if ( hm ) { 316 out += '<p class="cas-msg-heading">' + inline( hm[ 1 ] ) + '</p>'; 317 var rest = lines.slice( 1 ).join( '\n' ).trim(); 318 if ( rest ) { out += '<p>' + inline( rest ) + '</p>'; } 319 return; 320 } 321 322 out += '<p>' + inline( block ) + '</p>'; 323 } ); 324 325 return out || '<p>' + inline( text ) + '</p>'; 326 } 327 328 function chatCardFromArticle( a ) { 329 if ( ! a ) { return ''; } 330 var thumb = a.thumb ? '<img src="' + esc( a.thumb ) + '" alt="">' : '<img alt="">'; 331 return '<a class="cas-chat-card" target="_blank" rel="noopener" href="' + esc( a.url ) + '">' + 332 thumb + 333 '<div>' + 334 ( a.category ? '<p class="cas-article-cat">' + esc( a.category ) + '</p>' : '' ) + 335 '<div class="cas-chat-card-title">' + esc( a.title ) + '</div>' + 336 '</div></a>'; 337 } 338 339 // Build the preview card from the article the assistant actually recommends 340 // (the first link in its answer), not the top search result â those differ 341 // once answers are localised by country. Only show a card when the 342 // recommended URL is among the search results (so we have its thumbnail); 343 // otherwise show no card rather than a mismatched one. 344 function chatCardForAnswer( answerHtml ) { 345 var m = /<a[^>]*href="([^"]+)"[^>]*>/i.exec( answerHtml || '' ); 346 if ( ! m ) { return ''; } 347 var norm = function ( u ) { return ( u || '' ).replace( /\/+$/, '' ).toLowerCase(); }; 348 var target = norm( m[ 1 ] ); 349 for ( var i = 0; i < lastArticles.length; i++ ) { 350 if ( norm( lastArticles[ i ].url ) === target ) { 351 return chatCardFromArticle( lastArticles[ i ] ); 352 } 353 } 354 return ''; 355 } 356 357 function sendToChat( prompt, attachCard ) { 358 prompt = $.trim( prompt ); 359 if ( ! prompt ) { return; } 360 appendMsg( 'user', esc( prompt ) ); 361 362 if ( ! cfg.chatEnabled ) { 363 appendMsg( 'bot', 'The AI chat assistant isnât configured on this site yet, but you can explore the related articles from your search.' ); 364 return; 365 } 366 367 var $thinking = appendMsg( 'bot', 'Thinkingâ¦', 'cas-msg-thinking' ); 368 var session = chatSession; 369 370 $.ajax( { 371 url: cfg.chatUrl, 372 method: 'POST', 373 contentType: 'application/json', 374 headers: { 'X-WP-Nonce': cfg.nonce }, 375 data: JSON.stringify( { prompt: prompt, thread_id: chatThreadI
375d } ) 376 } ).done( function ( res ) { 377 if ( session !== chatSession ) { return; } // session was reset mid-flight â discard 378 $thinking.remove(); 379 if ( res && res.answer ) { 380 if ( res.thread_id ) { chatThreadId = res.thread_id; } 381 var card = attachCard ? chatCardForAnswer( res.answer ) : ''; 382 appendMsg( 'bot', formatAnswer( res.answer ) + card ); 383 } else { 384 appendMsg( 'bot', 'Sorry, I couldnât answer that. ' + esc( ( res && res.message ) || '' ) ); 385 } 386 } ).fail( function () { 387 if ( session !== chatSession ) { return; } // session was reset mid-flight â discard 388 $thinking.remove(); 389 appendMsg( 'bot', 'Sorry, the assistant is unavailable right now. Please try again.' ); 390 } ); 391 } 392 393 function openChat() { 394 showView( 'chat' ); 395 $overlay.find( '.cas-chat-search' ).val( lastQuery ); 396 $overlay.find( '.cas-searched-label' ).text( lastQuery ? 'Youâve searched â' + lastQuery + 'â' : '' ); 397 398 if ( ! chatSeeded ) { 399 $overlay.find( '.cas-messages' ).empty(); 400 appendMsg( 'bot', 'Hello, cat lovers! ð Iâm your smart search assistant. The more detailed your question is, the better I can help you! ð' ); 401 if ( lastQuery ) { 402 // Preview card temporarily disabled (pass true to re-enable). 403 sendToChat( lastQuery, false ); 404 } 405 chatSeeded = true; 406 } 407 setTimeout( function () { $overlay.find( '.cas-chat-input' ).trigger( 'focus' ); }, 50 ); 408 } 409 410 /* ---------- wiring ---------- */ 411 $( function () { 412 $overlay = $( '#cas-overlay' ); 413 if ( ! $overlay.length ) { return; } 414 $views = $overlay.find( '.cas-view' ); 415 416 // Intercept the theme's header search icon. 417 $( document ).on( 'click', '.toggle-search', function ( e ) { 418 e.preventDefault(); 419 e.stopImmediatePropagation(); 420 $( '.wrapper-search-top-bar' ).hide(); // suppress legacy search bar 421 openOverlay(); 422 } ); 423 424 // Hero submit 425 $overlay.on( 'submit', '.cas-searchbar--hero', function ( e ) { 426 e.preventDefault(); 427 runSearch( $( this ).find( '.cas-input' ).val() ); 428 } ); 429 430 // Results top-bar submit 431 $overlay.on( 'submit', '.cas-view--results .cas-searchbar--top', function ( e ) { 432 e.preventDefault(); 433 runSearch( $( this ).find( '.cas-input' ).val() ); 434 } ); 435 $overlay.on( 'click', '.cas-clear', function () { 436 var $input = $( this ).closest( '.cas-searchbar' ).find( '.cas-input' ).val( '' ).trigger( 'focus' ); 437 syncClear( $input ); 438 } ); 439 440 // Show/hide the clear button as the user types. 441 $overlay.on( 'input', '.cas-input', function () { 442 syncClear( $( this ) ); 443 } ); 444 445 // Recent search click 446 $overlay.on( 'click', '.cas-recent li', function () { 447 var q = $( this ).data( 'q' ); 448 if ( q ) { runSearch( q ); } 449 } ); 450 451 // Related Articles pagination 452 $overlay.on( 'click', '.cas-pagination .cas-page[data-page]', function () { 453 var target = parseInt( $( this ).attr( 'data-page' ), 10 ); 454 if ( target && target !== curPage && lastQuery ) { 455 loadArticlesPage( lastQuery, target ); 456 } 457 } ); 458 459 // Continue chatting 460 $overlay.on( 'click', '.cas-chat-btn', function () { openChat(); } ); 461 $overlay.on( 'click', '.cas-back-results', function () { showView( 'results' ); } ); 462 463 // Chat-view search box re-runs a search (runSearch resets the chat when 464 // the query changes, so a fresh "Continue chatting" re-seeds correctly). 465 $overlay.on( 'submit', '.cas-view--chat .cas-searchbar--top', function ( e ) { 466 e.preventDefault(); 467 var q = $( this ).find( '.cas-input' ).val(); 468 if ( $.trim( q ) ) { runSearch( q ); } 469 } ); 470 471 // Chat send 472 $overlay.on( 'submit', '.cas-chat-form', function ( e ) { 473 e.preventDefault(); 474 var $in = $( this ).find( '.cas-chat-input' ); 475 var v = $in.val(); 476 $in.val( '' ); 477 sendToChat( v, false ); 478 } ); 479 480 // Close 481 $overlay.on( 'click', '.cas-close', closeOverlay ); 482 $( document ).on( 'keydown', function ( e ) { 483 if ( e.key === 'Escape' && $overlay.hasClass( 'is-open' ) ) { closeOverlay(); } 484 } ); 485 } ); 486 487} )( jQuery );
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.