1(async function() { 2 const triggerId = hion_ai_search_params.id_trigger; 3 const defaultVal = hion_ai_search_params.default_value; 4 const primaryColor = hion_ai_search_params.primary_color; 5 const enableRelatedQuestions = hion_ai_search_params.enable_related_questions; 6 7 const triggerEl = document.getElementById(triggerId); 8 const modalCont = document.getElementById('hion-ai-modal-container'); 9 const backdrop = document.getElementById('hion-ai-backdrop'); 10 const modalClose = document.getElementById('hion-ai-modal-close'); 11 const inputField = document.getElementById('hion-ai-input'); 12 const sendBtn = document.getElementById('hion-ai-send-btn'); 13 const chatContent = document.getElementById('hion-ai-chat-content'); 14 15 // WordPress REST API endpoints 16 const ANSWER_ENDPOINT = hion_ai_search_params.answer_endpoint; 17 const SEARCH_ENDPOINT = hion_ai_search_params.search_endpoint; 18 19 // Variable to store focus 20 let elementToRestoreFocusTo = null; 21 22 function stripHTMLTags(str) { 23 if (typeof str !== "string") return ""; 24 return str.replace(/<\/?[^>]+(>|$)/g, ""); 25 } 26 27 // Modal open/close 28 function openModal() { 29 // Store current focus 30 elementToRestoreFocusTo = document.activeElement; 31 32 // Clear the input field 33 inputField.value = defaultVal; 34 sendBtn.disabled = false; 35 36 // Clear the chat content 37 chatContent.innerHTML = ''; 38 39 // Display the modal 40 modalCont.style.display = 'block'; 41 42 // Disable main page scrolling 43 document.body.style.overflow = 'hidden'; 44 45 // Focus on the input field 46 inputField.focus(); 47 } 48 49 function closeModal() { 50 modalCont.style.display = 'none'; 51 document.body.style.overflow = 'auto'; // Re-enable main page scrolling 52 if (elementToRestoreFocusTo) { 53 elementToRestoreFocusTo.focus(); // Restore focus 54 elementToRestoreFocusTo = null; 55 } 56 } 57 58 if (triggerEl) triggerEl.addEventListener('click', openModal); 59 if (backdrop) backdrop.addEventListener('click', closeModal); 60 if (modalClose) modalClose.addEventListener('click', closeModal); 61 62 // Close modal on ESC key press and handle Tab trapping 63 document.addEventListener('keydown', (event) => { 64 if (modalCont.style.display !== 'block') { 65 return; // Do nothing if modal is not open 66 } 67 68 if (event.key === 'Escape') { 69 closeModal(); 70 } 71 72 if (event.key === 'Tab') { 73 const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; 74 let focusableElements = Array.from(modalCont.querySelectorAll(focusableSelector)) 75 .filter(el => !el.disabled && el.offsetParent !== null && el.tabIndex !== -1); 76 77 if (focusableElements.length === 0) { 78 event.preventDefault(); // No focusable elements, prevent tabbing out 79 return; 80 } 81 82 const firstFocusableElement = focusableElements[0]; 83 const lastFocusableElement = focusableElements[focusableElements.length - 1]; 84 const activeElement = document.activeElement; 85 86 if (event.shiftKey) { // Shift + Tab 87 // If current focus is on the first element, or if the active element is not one of the modal'
87s focusable elements 88 if (activeElement === firstFocusableElement || !focusableElements.includes(activeElement)) { 89 event.preventDefault(); 90 lastFocusableElement.focus(); 91 } 92 } else { // Tab 93 // If current focus is on the last element, or if the active element is not one of the modal's focusable elements 94 if (activeElement === lastFocusableElement || !focusableElements.includes(activeElement)) { 95 event.preventDefault(); 96 firstFocusableElement.focus(); 97 } 98 } 99 } 100 }); 101 102 // Input: toggle button 103 inputField.addEventListener('input', () => { 104 if (inputField.value.trim() !== '') { 105 sendBtn.disabled = false; 106 } else { 107 sendBtn.disabled = true; 108 } 109 }); 110 111 // On send 112 async function handleSend() { 113 const questionText = inputField.value.trim(); 114 if (!questionText) return; 115 116 // Clear chat content 117 const userBubbles = chatContent.querySelectorAll('.user-bubble'); 118 chatContent.innerHTML = ''; 119 120 // Clear the input and disable the button, add loading state. 121 inputField.value = ''; 122 sendBtn.disabled = true; 123 sendBtn.classList.add('hion-loading'); 124 125 // Append user's question bubble (optional) 126 const userBubble = document.createElement('div'); 127 userBubble.className = 'hion-ai-bubble user-bubble'; 128 userBubble.innerHTML = `<p>${escapeHTML(questionText)}</p>`; 129 chatContent.appendChild(userBubble); 130 131 // Create the thinking bubble with animated dots. 132 const thinkingBubble = document.createElement('div'); 133 thinkingBubble.className = 'hion-ai-bubble bot-bubble thinking-bubble'; 134 thinkingBubble.innerHTML = ` 135 <div class="thinking"> 136 <span id="loading-text">Ladataan vastaus</span><span id="dots">.</span> 137 </div> 138 `; 139 chatContent.appendChild(thinkingBubble); 140 141 // Animate the dots (always 1 to 3 dots) 142 let dotCount = 1; 143 const dotsEl = thinkingBubble.querySelector('#dots'); 144 const dotInterval = setInterval(() => { 145 dotCount = (dotCount % 3) + 1; // cycles 1,2,3,1... 146 dotsEl.textContent = '.'.repeat(dotCount); 147 }, 500); 148 149 try { 150 // A) Fetch the AI answer using WordPress REST API 151 const answerResp = await fetch(ANSWER_ENDPOINT, { 152 method: "POST", 153 headers: { 154 "Content-Type": "application/json", 155 "X-WP-Nonce": hion_ai_search_params.nonce || '' 156 }, 157 body: JSON.stringify({ query: questionText }) 158 }); 159 160 if (!answerResp.ok) { 161 throw new Error(`Answer request failed: ${answerResp.status}`); 162 } 163 164 const answerData = await answerResp.json(); 165 clearInterval(dotInterval); 166 167 // Remove the thinking bubble 168 if (thinkingBubble.parentNode === chatContent) { 169 chatContent.removeChild(thinkingBubble); 170 } 171 172 // B) Display the AI answer 173 const aiAnswerText = answerData?.answer?.answerText; 174 if (aiAnswerText && aiAnswerText.trim() !== "Ei vastausta löytynyt") { 175 const answerBubble = document.createElement('div'); 176 answerBubble.className = 'hion-ai-bubble bot-bubble'; 177 178 // Create HTML content with answer and related questions 179 let bubbleContent = `<p>${aiAnswerText}</p>`; 180 answerBubble.innerHTML = bubbleContent; 181 let relatedQuestions = ''; 182 183 // Add related questions if they exist 184 if (answerData?.answer?.relatedQuestions?.length > 0) { 185 relatedQuestions = ` 186 <div class="related-questions"> 187 <div class="related-questions-header"> 188 <svg class="related-questions-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> 189 <path d="M11.4433 18.1418L11.895 17.3784L10.82 16.7418L10.3675 17.5051L11.4433 18.1418ZM8.10499 17.3784L8.55666 18.1418L9.63166 17.5051L9.18082 16.7418L8.10499 17.3784ZM10.3675 17.5051C10.3285 17.5674 10.2744 17.6188 10.2101 17.6545C10.1458 17.6901 10.0735 17.7088 9.99999 17.7088C9.92648 17.7088 9.85419 17.6901 9.7899 17.6545C9.72561 17.6188 9.67144 17.5674 9.63249 17.5051L8.55666 18.1418C9.20082 19.2309 10.7983 19.2309 11.4433 18.1418L10.3675 17.5051ZM8.74999 2.29175H11.25V1.04175H8.74999V2.29175ZM17.7083 8.75008V9.58342H18.9583V8.75008H17.7083ZM2.29166 9.58342V8.75008H1.04166V9.58342H2.29166ZM1.04166 9.58342C1.04166 10.5459 1.04166 11.2984 1.08332 11.9059C1.12499 12.5184 1.21082 13.0284 1.40666 13.5001L2.56166 13.0218C2.43916 12.7284 2.36749 12.3701 2.32999 11.8209C2.29166 11.2659 2.29166 10.5626 2.29166 9.58342H1.04166ZM6.50249 15.2018C5.45582 15.1834 4.90749 15.1168 4.47749 14.9384L3.99999 16.0934C4.67082 16.3718 5.43416 16.4343 6.48082 16.4526L6.50249 15.2018ZM1.40666 13.5001C1.64746 14.0815 2.00042 14.6097 2.44539 15.0547C2.89036 15.4997 3.41861 15.8526 3.99999 16.0934L4.47749 14.9384C3.60972 14.579 2.92111 13.8895 2.56166 13.0218L1.40666 13.5001ZM17.7083 9.58342C17.7083 10.5626 17.7083 11.2659 17.67 11.8209C17.6325 12.3701 17.5608 12.7284 17.4392 13.0218L18.5933 13.5001C18.7892 13.0284 18.875 12.5184 18.9175 11.9059C18.9592 11.2984 18.9583 10.5459 18.9583 9.58342H17.7083ZM13.5192 16.4509C14.5658 16.4334 15.3292 16.3718 16 16.0934L15.5217 14.9384C15.0925 15.1168 14.5442 15.1834 13.4983 15.2018L13.5192 16.4509ZM17.4392 13.0218C17.0797 13.8895 16.3903 14.579 15.5225 14.9384L16 16.0934C16.5814 15.8526 17.1096 15.4997 17.5546 15.0547C17.9996 14.6097 18.3525 14.0815 18.5933 13.5001L17.4392 13.0218ZM11.25 2.29175C12.6258 2.29175 13.6142 2.29175 14.385 2.36592C15.1467 2.43842 15.6308 2.57675 16.0175 2.81342L16.67 1.74758C16.0542 1.37092 15.3558 1.20258 14.5033 1.12092C13.66 1.04092 12.6017 1.04175 11.25 1.04175V2.29175ZM18.9583 8.75008C18.9583 7.39842 18.9583 6.34092 18.8783 5.49675C18.7975 4.64425 18.6292 3.94508 18.2525 3.33008L17.1858 3.98258C17.4233 4.36925 17.5617 4.85342 17.6342 5.61592C17.7075 6.38592 17.7083 7.37425 17.7083 8.75008H18.9583ZM16.0167 2.81342C16.4935 3.10533 16.8944 3.50598 17.1867 3.98258L18.2525 3.33008C17.8573 2.68506 17.315 2.14277 16.67 1.74758L16.0167 2.81342ZM8.74999 1.04175C7.39832 1.04175 6.34082 1.04175 5.49666 1.12092C4.64416 1.20258 3.94499 1.37092 3.32999 1.74758L3.98249 2.81425C4.36916 2.57675 4.85332 2.43842 5.61582 2.36592C6.38582 2.29258 7.37416 2.29175 8.74999 2.29175V1.04175ZM2.29166 8.75008C2.29166 7.37425 2.29166 6.38592 2.36582 5.61508C2.43832 4.85342 2.57666 4.36925 2.81332 3.98258L1.74749 3.33092C1.37082 3.94592 1.20249 4.64425 1.12082 5.49675C1.04166 6.34175 1.04166 7.39842 1.04166 8.75008H2.29166ZM3.32999 1.74758C2.68497 2.14277 2.14268 2.6859 1.74749 3.33092L2.81416 3.98258C3.10594 3.50617 3.50628 3.10554 3.98249 2.81342L3.32999 1.74758ZM9.17999 16.7418C9.01166 16.4559 8.86332 16.2034 8.71832 16.0051C8.56975 15.7871 8.3759 15.6038 8.14999 15.4676L7.52166 16.5484C7.56082 16.5718 7.61499 16.6134 7.70832 16.7418C7.80916 16.8801 7.92332 17.0718 8.10499 17.3784L9.17999 16.7418ZM6.48082 16.4509C6.84749 16.4576 7.07832 16.4626 7.25499 16.4818C7.41999 16.5001 7.48416 16.5268 7.52166 16.5484L8.14999 15.4676C7.91712 15.3372 7.6586 15.2593 7.39249 15.2393C7.14416 15.2118 6.84582 15.2076 6.50249 15.2018L6.48082 16.4509ZM11.895 17.3784C12.0767 17.0726 12.1908 16.8801 12.2917 16.7418C12.385 16.6134 12.4392 16.5
189718 12.4783 16.5484L11.85 15.4676C11.6083 15.6093 11.4333 15.7968 11.2817 16.0051C11.1375 16.2034 10.9883 16.4551 10.8192 16.7418L11.895 17.3784ZM13.4983 15.2018C13.155 15.2076 12.8558 15.2118 12.6075 15.2393C12.3483 15.2684 12.0942 15.3259 11.85 15.4676L12.4783 16.5484C12.5158 16.5268 12.58 16.5001 12.745 16.4818C12.9217 16.4626 13.1533 16.4568 13.52 16.4509L13.4983 15.2018Z" fill="#2F2F2F"></path> 190 <path fill-rule="evenodd" clip-rule="evenodd" d="M6.04166 7.5C6.04166 7.15482 6.32148 6.875 6.66666 6.875H13.3333C13.6785 6.875 13.9583 7.15482 13.9583 7.5C13.9583 7.84518 13.6785 8.125 13.3333 8.125H6.66666C6.32148 8.125 6.04166 7.84518 6.04166 7.5ZM6.04166 10.4167C6.04166 10.0715 6.32148 9.79167 6.66666 9.79167H11.25C11.5952 9.79167 11.875 10.0715 11.875 10.4167C11.875 10.7618 11.5952 11.0417 11.25 11.0417H6.66666C6.32148 11.0417 6.04166 10.7618 6.04166 10.4167Z" fill="#2F2F2F"></path> 191 </svg> 192 <strong>Ehdotukset</strong> 193 </div> 194 <div class="related-questions-list-container"> 195 <ul class="related-question-list"> 196 ${answerData.answer.relatedQuestions.map(q => 197 `<li class="related-question-list-item" role="button" tabindex="0">${q}</li>` 198 ).join('')} 199 </ul> 200 </div> 201 </div> 202 `; 203 } 204 205 chatContent.appendChild(answerBubble); 206 207 // Create DOM element from related questions string 208 if (relatedQuestions && enableRelatedQuestions) { 209 const relatedQuestionsEl = document.createElement('div'); 210 relatedQuestionsEl.innerHTML = relatedQuestions; 211 chatContent.appendChild(relatedQuestionsEl); 212 213 // Add click handlers to related questions 214 relatedQuestionsEl.querySelectorAll('.related-question-list-item').forEach(item => { 215 item.addEventListener('click', () => { 216 inputField.value = item.textContent; 217 handleSend(); 218 }); 219 220 // Add keyboard support 221 item.addEventListener('keydown', (e) => { 222 if (e.key === 'Enter' || e.key === ' ') { 223 e.preventDefault(); 224 inputField.value = item.textContent; 225 handleSend(); 226 } 227 }); 228 }); 229 } 230 231 } 232 233 // C) Fetch related search results using WordPress REST API 234 const searchResp = await fetch(SEARCH_ENDPOINT, { 235 method: "POST", 236 headers: { 237 "Content-Type": "application/json", 238 "X-WP-Nonce": hion_ai_search_params.nonce || '' 239 }, 240 body: JSON.stringify({ query: questionText }) 241 }); 242 243 if (!searchResp.ok) { 244 throw new Error(`Search request failed: ${searchResp.status}`); 245 } 246 247 const searchData = await searchResp.json(); 248 249 if (Array.isArray(searchData?.results) && searchData.results.length > 0) { 250 // Insert a heading for related content. 251 const relatedHeader = document.createElement('div'); 252 relatedHeader.className = 'related-header'; 253 relatedHeader.textContent = 'Aiheeseen liittyvä sisältö'; 254 chatContent.appendChild(relatedHeader); 255 256 // Append each search result box. 257 searchData.results.forEach(r => { 258 const doc = r.document || {}; 259 const derived = doc.derivedStructData || {}; 260 261 // Use 'htmlTitle' if available; otherwise, fallback to 'title' 262 const title = derived.htmlTitle || derived.title || "Ei otsikkoa"; 263 // Use the 'link' field for the URL. 264 const link = derived.link || "#"; 265 // Derive the display URL (strip protocol for cleaner output) 266 const displayLink = escapeHTML(link).replace(/^https?:\/\//i, ''); 267 // Retrieve snippet from first extractive answer if available. 268 let snippet = ""; 269 if (derived.extractive_answers && derived.extractive_answers.length > 0) {
270 snippet = derived.extractive_answers[0].content || "..."; 271 } else { 272 snippet = "(no snippet)"; 273 } 274 275 // Strip any html tags from the snippet, then escape it. 276 const safeSnippet = escapeHTML(stripHTMLTags(snippet)); 277 278 // Use content type if available; default to "BLOGI" 279 const contentType = derived.type || "BLOGI"; 280 281 // Build markup: the header now contains the content type text (with class 'content-type-text') 282 // and a link icon. Then the title, then the display URL, then the snippet. 283 const resultBox = document.createElement('div'); 284 resultBox.className = 'hion-result-box'; 285 resultBox.innerHTML = ` 286 <div class="hion-result-title"> 287 <a href="${escapeHTML(link)}">${escapeHTML(title)}</a> 288 </div> 289 <div class="hion-result-display-link"> 290 ${displayLink} 291 </div> 292 <div class="hion-result-snippet"> 293 ${safeSnippet} 294 </div> 295 `; 296 chatContent.appendChild(resultBox); 297 }); 298 } 299 300 301 302 303 304 305 // Finally, keep the scroll position at the top (smoothly, if CSS scroll-behavior is set) 306 chatContent.scrollTop = 0; 307 308 } catch (error) { 309 console.error(error); 310 clearInterval(dotInterval); 311 thinkingBubble.innerHTML = `<p style="color:red;">Virhe: ${error.message}</p>`; 312 } finally { 313 sendBtn.classList.remove('hion-loading'); 314 if (inputField.value.trim() !== '') { 315 sendBtn.disabled = false; 316 } else { 317 sendBtn.disabled = true; 318 } 319 inputField.focus(); 320 } 321 } 322 323 sendBtn.addEventListener('click', handleSend); 324 inputField.addEventListener('keydown', (e) => { 325 if (e.key === 'Enter') { 326 e.preventDefault(); 327 handleSend(); 328 } 329 }); 330 331 function escapeHTML(str) { 332 if (typeof str !== "string") return ""; 333 return str.replace(/[&<>'"]/g, c => { 334 switch (c) { 335 case '&': return '&'; 336 case '<': return '<'; 337 case '>': return '>'; 338 case '"': return '"'; 339 case "'": return '''; 340 default: return c; 341 } 342 }); 343 } 344 345})();
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.