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