1class AIChatChat extends HTMLElement { 2 #root = null; 3 #controls = null; 4 #input = null; 5 #output = null; 6 #progress = null; 7 #pagecontext = null; 8 #history = []; 9 10 constructor() { 11 super(); 12 13 this.#root = this.attachShadow({mode: 'open'}); 14 this.#root.innerHTML = ` 15 <div class="output"></div> 16 <form> 17 <progress max="100" value="0"></progress> 18 <div class="controls"> 19 <div class="col"> 20 <button type="button" class="pagecontext toggle off" title="pagecontext"> 21 <svg viewBox="0 0 24 24"><path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z" /></svg> 22 </button> 23 </div> 24 25 <textarea autofocus></textarea> 26 27 <div class="col"> 28 <button type="button" class="delete-history" title="restart"> 29 <svg viewBox="0 0 24 24"><path d="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" /></svg> 30 </button> 31 <button type="submit" class="send" title="send"> 32 <svg viewBox="0 0 24 24"><path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" /></svg> 33 </button> 34 </div> 35 </div> 36 </form> 37 `; 38 this.#root.appendChild(this.getStyle()); 39 this.#input = this.#root.querySelector('textarea'); 40 this.#output = this.#root.querySelector('.output'); 41 this.#progress = this.#root.querySelector('progress'); 42 this.#controls = this.#root.querySelector('.controls'); 43 this.#pagecontext = this.#root.querySelector('.pagecontext'); 44 const form = this.#root.querySelector('form'); 45 form.addEventListener('submit', this.onSubmit.bind(this)); 46 const restart = this.#root.querySelector('.delete-history'); 47 restart.addEventListener('click', this.deleteHistory.bind(this)); 48 this.#input.addEventListener('keydown', (event) => { 49 if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) { 50 event.preventDefault(); 51 this.onSubmit(event); 52 } 53 }); 54 this.#pagecontext.addEventListener('click', (event) => { 55 this.#pagecontext.classList.toggle('off'); 56 }); 57 } 58 59 /** 60 * Called when the DOM has been connected 61 * 62 * We initialize the attribute based states here 63 */ 64 connectedCallback() { 65 this.#input.placeholder = this.getAttribute('placeholder') || 'Your question...'; 66 this.displayMessage(this.getAttribute('hello') || 'Hello, how can I help you?', {}); 67 68 // make title attributes translatable 69 for (const elem of this.#root.querySelectorAll('[title]')) { 70 elem.title = this.getAttribute('title-'+elem.title) || elem.title; 71 } 72 73 this.restoreHistory(); 74 this.stopProgress(); // initializes the visibility states 75 } 76 77 /** 78 * Define the web component's internal styles 79 * 80 * @returns {HTMLStyleElement} 81 */ 82 getStyle() { 83 const style = document.createElement('style'); 84 style.textContent = ` 85 :host { 86 --color-human: #ebd8b2; 87 --color-ai: #c6dbf2; 88 --color-link: #4881bf; 89 90 display: flex; 91 flex-direction: column; 92 height: 100%; 93 justify-content: space-between; 94 } 95 96 * { 97 box-sizing: border-box; 98 font-family: sans-serif; 99 } 100 form { 101 clear: both; 102 103 } 104 .controls { 105 width: 100%; 106 display: flex; 107 flex-direction: row; 108 align-items: flex-end; 109 gap: 0.25em; 110 } 111 112 .controls .col { 113 display: flex; 114 flex-direction: column; 115 gap: 0.25em; 116 } 117 118 .controls button { 119 padding: 0; 120 background: none; 121 border: none; 122 cursor: pointer; 123 display: flex; 124 width: 2.5em; 125 } 126 .controls button svg { 127 flex-grow: 1; 128 flex-shrink: 1; 129 fill: var(--color-link); 130 } 131 132 .controls button.toggle.off svg { 133 fill: #ccc; 134 } 135 136 .controls textarea { 137 width: 100%; 138 padding: 0.25em; 139 font-size: 1.2em; 140 height: 5em; 141 border: none; 142 resize: vertical; 143 } 144 .controls textarea:focus { 145 outline: none; 146 } 147 progress{ 148 width: 100%; 149 } 150 progress { 151 color: var(--color-link); 152 accent-color: var(--color-link); 153 } 154 a { 155 color: var(--color-link); 156 } 157 .output > div { 158 border-radius: 0.25em; 159 clear: both; 160 padding: 0.5em 1em; 161 position: relative; 162 margin-bottom: 1em; 163 } 164 .output > div::before { 165 content: ""; 166 width: 0px; 167 height: 0px; 168 position: absolute; 169 top: 0; 170 } 171 .output > div.user { 172 background-color: var(--color-human); 173 float: right; 174 margin-right: 2em; 175 border-top-right-radius: 0; 176 } 177 .output > div.user::before { 178 right: -1em; 179 border-left: 0.5em solid var(--color-human); 180 border-right: 0.5em solid transparent; 181 border-top: 0.5em solid var(--color-human); 182 border-bottom: 0.5em solid transparent; 183 } 184 .output > div.ai { 185 background-color: var(--color-ai); 186 float: left; 187 margin-left: 2em; 188 border-top-left-radius: 0; 189 } 190 .output > div.ai::before { 191 left: -1em; 192 border-left: 0.5em solid transparent; 193 border-right: 0.5em solid var(--color-ai); 194 border-top: 0.5em solid var(--color-ai); 195 border-bottom: 0.5em solid transparent; 196 } 197 `; 198 return style; 199 } 200 201 /** 202 * Save history to session storage 203 */ 204 saveHistory() { 205 sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history)); 206 } 207 208 /** 209 * Load the history from session storage and display it 210 */ 211 restoreHistory() { 212 const history = sessionStorage.getItem('ai-chat-history'); 213 if (history) { 214 this.#history = JSON.parse(history); 215 this.#history.forEach(row => { 216 this.displayMessage(row[0]); 217 this.displayMessage(row[1], row[2]); 218 }); 219 } 220 } 221 222 /** 223 * Clear the history and reset the chat 224 */ 225 deleteHistory() { 226 sessionStorage.removeItem('ai-chat-history'); 227 this.#history = []; 228 this.#output.innerHTML = ''; 229 this.connectedCallback(); // re-initialize 230 } 231 232 /** 233 * Get the current page context if enabled, empty string otherwise 234 * 235 * @returns {string} 236 */ 237 getPageContext() { 238 return this.#pagecontext.classList.contains('off') ? '' : JSINFO.id; 239 } 240 241 /** 242 * Submit the given question to the server 243 * 244 * @param event 245 * @returns {Promise<void>} 246 */ 247 async onSubmit(event) { 248 event.preventDefault(); 249 const message = this.#input.value; 250 if (!message) return; 251 252 // show original question for now 253 const p = this.displayMessage(message); 254 255 this.#input.value = ''; 256 this.startProgress(); 257 try { 258 const response = await this.sendMessage(message, this.getPageContext()); 259 this.#history.push([response.question, response.answer, response.sources]); 260 this.saveHistory(); 261 p.textContent = response.question; // replace original question with interpretation 262 p.title = message; // show original question on hover 263 this.displayMessage(response.answer, response.sources); // display the answer 264 } catch (e) { 265 console.error(e); 266 this.displayMessage(LANG.plugins.aichat.error, {}); 267 } 268 269 this.stopProgress(); 270 this.#pagecontext.classList.add('off'); // disable page context for next question 271 this.#input.focus(); 272 p.scrollIntoView(); 273 } 274 275 /** 276 * Called when waiting for the response has started 277 * 278 * Hides the input field and shows the progress bar 279 */ 280 startProgress() { 281 this.#controls.style.display = 'none'; 282 this.#progress.style.display = 'block'; 283 this.#progress.value = 0; 284 285 this.#progress._timer = setInterval(() => { 286 this.#progress.scrollIntoView(); 287 const missing = this.#progress.max - this.#progress.value; 288 const add = missing / 100; // we will never reach 100% 289 this.#progress.value += add; 290 }, 100); 291 } 292 293 /** 294 * Called when waiting for the response has finished 295 * 296 * Resets the progress bar and shows the input field again 297 */ 298 stopProgress() { 299 if (this.#progress._timer) { 300 clearInterval(this.#progress._timer); 301 this.#progress._timer = null; 302 } 303 this.#controls.style.removeProperty('display'); 304 this.#progress.style.display = 'none'; 305 } 306 307 /** 308 * Display a message in the chat 309 * 310 * @param {string} message 311 * @param {object|null} sources Dict of sources {url:title, ...} if given this is assumed to be an AI message 312 * @returns {HTMLParagraphElement} Reference to the newly added message 313 */ 314 displayMessage(message, sources = null) { 315 const div = document.createElement('div'); 316 if(sources !== null) { 317 div.classList.add('ai'); 318 div.innerHTML = message; // we get HTML for AI messages 319 } else { 320 div.classList.add('user'); 321 div.textContent = message; 322 } 323 324 if (sources !== null && sources.length > 0) { 325 const ul = document.createElement('ul'); 326 sources.forEach((source) => { 327 const li = document.createElement('li'); 328 const a = document.createElement('a'); 329 a.href = source.url; 330 a.textContent = source.title; 331 a.title = `${source.page} (${source.score})`; 332 li.appendChild(a); 333 ul.appendChild(li); 334 }); 335 div.appendChild(ul); 336 } 337 338 this.#output.appendChild(div); 339 return div; 340 } 341 342 /** 343 * Send a question to the server 344 * 345 * @param {string} message 346 * @param {string} pageContext The current page ID if it should be used as context 347 * @returns {Promise<object>} 348 */ 349 async sendMessage(message, pageContext = '') { 350 const formData = new FormData(); 351 formData.append('question', message); 352 formData.append('history', JSON.stringify(this.#history)); 353 formData.append('pagecontext', pageContext); 354 355 const response = await fetch(this.getAttribute('url') || '/', { 356 method: 'POST', 357 body: formData 358 }); 359 360 return await response.json(); 361 } 362} 363 364window.customElements.define('aichat-chat', AIChatChat); 365