PageSourceSearch

https://www.yrittajat.fi/app/plugins/hion-ai-search/assets/scripts/ai-search.js?ver=1.4

js yrittajat.fi collected 2026-09-24 08:46:50 UTC 17,063 bytes, 345 lines download raw bytes

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 '&amp;';
336        case '<': return '&lt;';
337        case '>': return '&gt;';
338        case '"': return '&quot;';
339        case "'": return '&#39;';
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.