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.#output.innerHTML = ''; 66 this.#input.placeholder = this.getAttribute('placeholder') || 'Your question...'; 67 this.displayMessage(this.getAttribute('hello') || 'Hello, how can I help you?', {}); 68 69 // make title attributes translatable 70 for (const elem of this.#root.querySelectorAll('[title]')) { 71 elem.title = this.getAttribute('title-'+elem.title) || elem.title; 72 } 73 74 this.restoreHistory(); 75 this.stopProgress(); // initializes the visibility states 76 } 77 78 /** 79 * Define the web component's internal styles 80 * 81 * @returns {HTMLStyleElement} 82 */ 83 getStyle() { 84 const style = document.createElement('style'); 85 style.textContent = ` 86 :host { 87 --color-human: #ebd8b2; 88 --color-ai: #c6dbf2; 89 --color-link: #4881bf; 90 91 display: flex; 92 flex-direction: column; 93 height: 100%; 94 justify-content: space-between; 95 } 96 97 * { 98 box-sizing: border-box; 99 font-family: sans-serif; 100 } 101 form { 102 clear: both; 103 104 } 105 .controls { 106 width: 100%; 107 display: flex; 108 flex-direction: row; 109 align-items: flex-end; 110 gap: 0.25em; 111 } 112 113 .controls .col { 114 display: flex; 115 flex-direction: column; 116 gap: 0.25em; 117 } 118 119 .controls button { 120 padding: 0; 121 background: none; 122 border: none; 123 cursor: pointer; 124 display: flex; 125 width: 2.5em; 126 } 127 .controls button svg { 128 flex-grow: 1; 129 flex-shrink: 1; 130 fill: var(--color-link); 131 } 132 133 .controls button.toggle.off svg { 134 fill: #ccc; 135 } 136 137 .controls textarea { 138 width: 100%; 139 padding: 0.25em; 140 font-size: 1.2em; 141 height: 5em; 142 border: none; 143 resize: vertical; 144 } 145 .controls textarea:focus { 146 outline: none; 147 } 148 progress{ 149 width: 100%; 150 } 151 progress { 152 color: var(--color-link); 153 accent-color: var(--color-link); 154 } 155 a { 156 color: var(--color-link); 157 } 158 .output > div { 159 border-radius: 0.25em; 160 clear: both; 161 padding: 0.5em 1em; 162 position: relative; 163 margin-bottom: 1em; 164 max-width: calc(100% - 4em); 165 } 166 .output > div::before { 167 content: ""; 168 width: 0px; 169 height: 0px; 170 position: absolute; 171 top: 0; 172 } 173 .output > div.user { 174 background-color: var(--color-human); 175 float: right; 176 margin-right: 2em; 177 border-top-right-radius: 0; 178 } 179 .output > div.user::before { 180 right: -1em; 181 border-left: 0.5em solid var(--color-human); 182 border-right: 0.5em solid transparent; 183 border-top: 0.5em solid var(--color-human); 184 border-bottom: 0.5em solid transparent; 185 } 186 .output > div.ai { 187 background-color: var(--color-ai); 188 float: left; 189 margin-left: 2em; 190 border-top-left-radius: 0; 191 } 192 .output > div.ai::before { 193 left: -1em; 194 border-left: 0.5em solid transparent; 195 border-right: 0.5em solid var(--color-ai); 196 border-top: 0.5em solid var(--color-ai); 197 border-bottom: 0.5em solid transparent; 198 } 199 .output pre { 200 max-width: 100%; 201 overflow: auto; 202 scrollbar-width: thin; 203 } 204 .output > div.ai pre { 205 scrollbar-color: var(--color-link) var(--color-ai); 206 } 207 .output > div.human pre { 208 scrollbar-color: var(--color-link) var(--color-human); 209 } 210 `; 211 return style; 212 } 213 214 /** 215 * Save history to session storage 216 */ 217 saveHistory() { 218 sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history)); 219 } 220 221 /** 222 * Load the history from session storage and display it 223 */ 224 restoreHistory() { 225 const history = sessionStorage.getItem('ai-chat-history'); 226 if (history) { 227 this.#history = JSON.parse(history); 228 this.#history.forEach(row => { 229 this.displayMessage(row[0]); 230 this.displayMessage(row[1], row[2]); 231 }); 232 } 233 } 234 235 /** 236 * Clear the history and reset the chat 237 */ 238 deleteHistory() { 239 sessionStorage.removeItem('ai-chat-history'); 240 this.#history = []; 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