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 max-width: calc(100% - 4em); 164 } 165 .output > div::before { 166 content: ""; 167 width: 0px; 168 height: 0px; 169 position: absolute; 170 top: 0; 171 } 172 .output > div.user { 173 background-color: var(--color-human); 174 float: right; 175 margin-right: 2em; 176 border-top-right-radius: 0; 177 } 178 .output > div.user::before { 179 right: -1em; 180 border-left: 0.5em solid var(--color-human); 181 border-right: 0.5em solid transparent; 182 border-top: 0.5em solid var(--color-human); 183 border-bottom: 0.5em solid transparent; 184 } 185 .output > div.ai { 186 background-color: var(--color-ai); 187 float: left; 188 margin-left: 2em; 189 border-top-left-radius: 0; 190 } 191 .output > div.ai::before { 192 left: -1em; 193 border-left: 0.5em solid transparent; 194 border-right: 0.5em solid var(--color-ai); 195 border-top: 0.5em solid var(--color-ai); 196 border-bottom: 0.5em solid transparent; 197 } 198 .output pre { 199 max-width: 100%; 200 overflow: auto; 201 scrollbar-width: thin; 202 } 203 .output > div.ai pre { 204 scrollbar-color: var(--color-link) var(--color-ai); 205 } 206 .output > div.human pre { 207 scrollbar-color: var(--color-link) var(--color-human); 208 } 209 `; 210 return style; 211 } 212 213 /** 214 * Save history to session storage 215 */ 216 saveHistory() { 217 sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history)); 218 } 219 220 /** 221 * Load the history from session storage and display it 222 */ 223 restoreHistory() { 224 const history = sessionStorage.getItem('ai-chat-history'); 225 if (history) { 226 this.#history = JSON.parse(history); 227 this.#history.forEach(row => { 228 this.displayMessage(row[0]); 229 this.displayMessage(row[1], row[2]); 230 }); 231 } 232 } 233 234 /** 235 * Clear the history and reset the chat 236 */ 237 deleteHistory() { 238 sessionStorage.removeItem('ai-chat-history'); 239 this.#history = []; 240 this.#output.innerHTML = ''; 241 this.connectedCallback(); // re-initialize 242 } 243 244 /** 245 * Get the current page context if enabled, empty string otherwise 246 * 247 * @returns {string} 248 */ 249 getPageContext() { 250 return this.#pagecontext.classList.contains('off') ? '' : JSINFO.id; 251 } 252 253 /** 254 * Submit the given question to the server 255 * 256 * @param event 257 * @returns {Promise<void>} 258 */ 259 async onSubmit(event) { 260 event.preventDefault(); 261 const message = this.#input.value; 262 if (!message) return; 263 264 // show original question for now 265 const p = this.displayMessage(message); 266 267 this.#input.value = ''; 268 this.startProgress(); 269 try { 270 const response = await this.sendMessage(message, this.getPageContext()); 271 this.#history.push([response.question, response.answer, response.sources]); 272 this.saveHistory(); 273 p.textContent = response.question; // replace original question with interpretation 274 p.title = message; // show original question on hover 275 this.displayMessage(response.answer, response.sources); // display the answer 276 } catch (e) { 277 console.error(e); 278 this.displayMessage(LANG.plugins.aichat.error, {}); 279 } 280 281 this.stopProgress(); 282 this.#input.focus(); 283 p.scrollIntoView(); 284 } 285 286 /** 287 * Called when waiting for the response has started 288 * 289 * Hides the input field and shows the progress bar 290 */ 291 startProgress() { 292 this.#controls.style.display = 'none'; 293 this.#progress.style.display = 'block'; 294 this.#progress.value = 0; 295 296 this.#progress._timer = setInterval(() => { 297 this.#progress.scrollIntoView(); 298 const missing = this.#progress.max - this.#progress.value; 299 const add = missing / 100; // we will never reach 100% 300 this.#progress.value += add; 301 }, 100); 302 } 303 304 /** 305 * Called when waiting for the response has finished 306 * 307 * Resets the progress bar and shows the input field again 308 */ 309 stopProgress() { 310 if (this.#progress._timer) { 311 clearInterval(this.#progress._timer); 312 this.#progress._timer = null; 313 } 314 this.#controls.style.removeProperty('display'); 315 this.#progress.style.display = 'none'; 316 } 317 318 /** 319 * Display a message in the chat 320 * 321 * @param {string} message 322 * @param {object|null} sources Dict of sources {url:title, ...} if given this is assumed to be an AI message 323 * @returns {HTMLParagraphElement} Reference to the newly added message 324 */ 325 displayMessage(message, sources = null) { 326 const div = document.createElement('div'); 327 if(sources !== null) { 328 div.classList.add('ai'); 329 div.innerHTML = message; // we get HTML for AI messages 330 } else { 331 div.classList.add('user'); 332 div.textContent = message; 333 } 334 335 if (sources !== null && sources.length > 0) { 336 const ul = document.createElement('ul'); 337 sources.forEach((source) => { 338 const li = document.createElement('li'); 339 const a = document.createElement('a'); 340 a.href = source.url; 341 a.textContent = source.title; 342 a.title = `${source.page} (${source.score})`; 343 li.appendChild(a); 344 ul.appendChild(li); 345 }); 346 div.appendChild(ul); 347 } 348 349 this.#output.appendChild(div); 350 return div; 351 } 352 353 /** 354 * Send a question to the server 355 * 356 * @param {string} message 357 * @param {string} pageContext The current page ID if it should be used as context 358 * @returns {Promise<object>} 359 */ 360 async sendMessage(message, pageContext = '') { 361 const formData = new FormData(); 362 formData.append('question', message); 363 formData.append('history', JSON.stringify(this.#history)); 364 formData.append('pagecontext', pageContext); 365 366 const response = await fetch(this.getAttribute('url') || '/', { 367 method: 'POST', 368 body: formData 369 }); 370 371 return await response.json(); 372 } 373} 374 375window.customElements.define('aichat-chat', AIChatChat); 376