1import { Controller } from '@hotwired/stimulus'; 2 3export default class extends Controller { 4 static targets = [ 5 'loader', 6 'spinner', 7 'panel', 8 'text', 9 'sources', 10 'sourcesTitle', 11 'sourcesList', 12 'feedback', 13 ]; 14 15 static values = { 16 url: String, 17 sourcesTitle: String, 18 thinking: String, 19 showSources: { type: Boolean, default: true }, 20 }; 21 22 connect() { 23 this.typewriterTimer = null; 24 this.currentAnswerHtml = ''; 25 this.boundBeforeCache = this.handleBeforeCache.bind(this); 26 document.addEventListener('turbo:before-cache', this.boundBeforeCache); 27 28 if (this.isAnswerAlreadyRendered()) { 29 this.currentAnswerHtml = this.textTarget.innerHTML; 30 this.markAnswerComplete(); 31 return; 32 } 33 34 this.scheduleFetch(); 35 } 36 37 disconnect() { 38 this.stopTypewriter(); 39 document.removeEventListener('turbo:before-cache', this.boundBeforeCache); 40 } 41 42 handleBeforeCache() { 43 this.stopTypewriter(); 44 45 if (!this.hasTextTarget) { 46 return; 47 } 48 49 const renderedText = this.textTarget.innerHTML.trim() || this.textTarget.textContent.trim(); 50 if (renderedText === '') { 51 return; 52 } 53 54 if (this.textTarget.innerHTML.trim() === '' && this.currentAnswerHtml !== '') { 55 this.textTarget.innerHTML = this.currentAnswerHtml; 56 } 57 58 this.currentAnswerHtml = this.textTarget.innerHTML || this.currentAnswerHtml; 59 this.markAnswerComplete(); 60 } 61 62 isAnswerAlreadyRendered() { 63 if (!this.hasPanelTarget || this.panelTarget.hidden || !this.hasTextTarget) { 64 return false; 65 } 66 67 return this.textTarget.innerHTML.trim() !== '' || this.textTarget.textContent.trim() !== ''; 68 } 69 70 scheduleFetch() { 71 const run = () => { 72 this.fetchAnswer().catch(() => this.hideSection()); 73 }; 74 75 if (document.readyState === 'complete') { 76 window.requestAnimationFrame(run); 77 return; 78 } 79 80 window.addEventListener('load', () => window.requestAnimationFrame(run), { once: true }); 81 } 82 83 async fetchAnswer() { 84 const response = await fetch(this.urlValue, { 85 method: 'GET', 86 headers: { Accept: 'application/json' }, 87 credentials: 'same-origin', 88 }); 89 90 if (!response.ok) { 91 this.hideSection(); 92 return; 93 } 94 95 const data = await response.json(); 96 if (!data || data.available !== true || typeof data.answerHtml !== 'string' || data.answerHtml.trim() === '') { 97 this.hideSection(); 98 return; 99 } 100 101 this.renderAnswer(data); 102 } 103 104 renderAnswer(data) { 105 this.loaderTarget.hidden = true; 106 107 this.pendingSources = data; 108 this.currentAnswerHtml = data.answerHtml; 109 this.sourcesTarget.hidden = true; 110 111 this.panelTarget.hidden = false; 112 this.typewriter(data.answerHtml); 113 } 114 115 typewriter(html) { 116 const plain = this.stripHtml(html); 117 this.textTarget.textContent = ''; 118 119 if (plain === '') { 120 this.textTarget.innerHTML = html; 121 this.finishAnswer(); 122 return; 123 } 124 125 const speed = plain.length > 600 ? 4 : plain.length > 300 ? 6 : 9; 126 let index = 0; 127 128 this.stopTypewriter(); 129 130 this.typewriterTimer = window.setInterval(() => { 131 index += 1; 132 this.textTarget.textContent = plain.slice(0, index); 133 134 if (index >= plain.length) { 135 this.stopTypewriter(); 136 this.textTarget.innerHTML = html; 137 this.finishAnswer(); 138 } 139 }, speed); 140 } 141 142 finishAnswer() { 143 const data = this.pendingSources; 144 145 try { 146 if (this.showSourcesValue && data && data.showSources === true && Array.isArray(data.sources) && data.sources.length > 0) { 147 this.sourcesTitleTarget.textContent = this.sourcesTitleValue; 148 this.sourcesListTarget.replaceChildren(); 149
150 data.sources.forEach((source) => { 151 if (!source || typeof source.url !== 'string' || source.url === '') { 152 return; 153 } 154 155 const li = document.createElement('li'); 156 const link = document.createElement('a'); 157 link.href = source.url; 158 link.textContent = typeof source.title === 'string' && source.title !== '' ? source.title : source.url; 159 li.appendChild(link); 160 this.sourcesListTarget.appendChild(li); 161 }); 162 163 this.sourcesTarget.hidden = this.sourcesListTarget.children.length === 0; 164 } 165 } finally { 166 this.markAnswerComplete(); 167 } 168 } 169 170 markAnswerComplete() { 171 this.element.classList.remove('search-ai-answer--loading'); 172 this.element.setAttribute('aria-busy', 'false'); 173 174 const panel = this.element.querySelector('.search-ai-answer__panel'); 175 panel?.classList.add('search-ai-answer__panel--complete'); 176 177 const feedbackEl = this.element.querySelector('.search-ai-answer__feedback'); 178 if (feedbackEl) { 179 feedbackEl.hidden = false; 180 feedbackEl.removeAttribute('hidden'); 181 } 182 183 if (this.currentAnswerHtml !== '') { 184 const aiAnswerInput = this.element.querySelector('[data-search-ai-answer-feedback-target="aiAnswerInput"]'); 185 if (aiAnswerInput) { 186 aiAnswerInput.value = this.currentAnswerHtml; 187 } 188 } 189 } 190 191 stripHtml(html) { 192 const node = document.createElement('div'); 193 node.innerHTML = html; 194 return (node.textContent || node.innerText || '').replace(/\s+/g, ' ').trim(); 195 } 196 197 stopTypewriter() { 198 if (this.typewriterTimer !== null) { 199 window.clearInterval(this.typewriterTimer); 200 this.typewriterTimer = null; 201 } 202 } 203 204 hideSection() { 205 this.stopTypewriter(); 206 this.element.hidden = true; 207 this.element.setAttribute('aria-busy', 'false'); 208 } 209}
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.