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 form.dispatchEvent(new Event('submit')); 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.#input.placeholder = this.getAttribute('placeholder') || 'Your question...'; 52 this.displayMessage(this.getAttribute('hello') || 'Hello, how can I help you?', {}); 53 54 // make title attributes translatable 55 for (const elem of this.#root.querySelectorAll('[title]')) { 56 elem.title = this.getAttribute('title-'+elem.title) || elem.title; 57 } 58 59 this.restoreHistory(); 60 this.stopProgress(); // initializes the visibility states 61 } 62 63 /** 64 * Define the web component's internal styles 65 * 66 * @returns {HTMLStyleElement} 67 */ 68 getStyle() { 69 const style = document.createElement('style'); 70 style.textContent = ` 71 :host { 72 --color-human: #ebd8b2; 73 --color-ai: #c6dbf2; 74 --color-link: #4881bf; 75 76 display: flex; 77 flex-direction: column; 78 height: 100%; 79 justify-content: space-between; 80 } 81 82 * { 83 box-sizing: border-box; 84 font-family: sans-serif; 85 } 86 form { 87 clear: both; 88 margin-bottom: 1em; 89 } 90 .controls { 91 width: 100%; 92 position: relative; 93 } 94 .controls button { 95 padding: 0; 96 background: none; 97 border: none; 98 cursor: pointer; 99 display: flex; 100 width: 2.5em; 101 102 position: absolute; 103 bottom: 2px; 104 z-index: 1; 105 } 106 .controls button.delete-history { 107 left: 5px; 108 } 109 .controls button.send { 110 right: 5px; 111 } 112 .controls button svg { 113 flex-grow: 1; 114 flex-shrink: 1; 115 fill: var(--color-link); 116 } 117 .controls textarea { 118 width: 100%; 119 padding: 0.25em 3em; 120 font-size: 1.2em; 121 height: 4em; 122 border: none; 123 resize: vertical; 124 } 125 .controls textarea:focus { 126 outline: none; 127 } 128 progress{ 129 width: 100%; 130 } 131 progress { 132 color: var(--color-link); 133 accent-color: var(--color-link); 134 } 135 a { 136 color: var(--color-link); 137 } 138 .output > div { 139 border-radius: 0.25em; 140 clear: both; 141 padding: 0.5em 1em; 142 position: relative; 143 margin-bottom: 1em; 144 } 145 .output > div::before { 146 content: ""; 147 width: 0px; 148 height: 0px; 149 position: absolute; 150 top: 0; 151 } 152 .output > div.user { 153 background-color: var(--color-human); 154 float: right; 155 margin-right: 2em; 156 border-top-right-radius: 0; 157 } 158 .output > div.user::before { 159 right: -1em; 160 border-left: 0.5em solid var(--color-human); 161 border-right: 0.5em solid transparent; 162 border-top: 0.5em solid var(--color-human); 163 border-bottom: 0.5em solid transparent; 164 } 165 .output > div.ai { 166 background-color: var(--color-ai); 167 float: left; 168 margin-left: 2em; 169 border-top-left-radius: 0; 170 } 171 .output > div.ai::before { 172 left: -1em; 173 border-left: 0.5em solid transparent; 174 border-right: 0.5em solid var(--color-ai); 175 border-top: 0.5em solid var(--color-ai); 176 border-bottom: 0.5em solid transparent; 177 } 178 `; 179 return style; 180 } 181 182 /** 183 * Save history to session storage 184 */ 185 saveHistory() { 186 sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history)); 187 } 188 189 /** 190 * Load the history from session storage and display it 191 */ 192 restoreHistory() { 193 const history = sessionStorage.getItem('ai-chat-history'); 194 if (history) { 195 this.#history = JSON.parse(history); 196 this.#history.forEach(row => { 197 this.displayMessage(row[0]); 198 this.displayMessage(row[1], row[2]); 199 }); 200 } 201 } 202 203 deleteHistory() { 204 sessionStorage.removeItem('ai-chat-history'); 205 this.#history = []; 206 this.#output.innerHTML = ''; 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('Sorry, something went wrong', {}); 236 } 237 238 this.stopProgress(); 239 this.#input.focus(); 240 } 241 242 /** 243 * Called when waiting for the response has started 244 * 245 * Hides the input field and shows the progress bar 246 */ 247 startProgress() { 248 this.#controls.style.display = 'none'; 249 this.#progress.style.display = 'block'; 250 this.#progress.value = 0; 251 252 this.#progress._timer = setInterval(() => { 253 this.#progress.scrollIntoView(); 254 const missing = this.#progress.max - this.#progress.value; 255 const add = missing / 100; // we will never reach 100% 256 this.#progress.value += add; 257 }, 100); 258 } 259 260 /** 261 * Called when waiting for the response has finished 262 * 263 * Resets the progress bar and shows the input field again 264 */ 265 stopProgress() { 266 if (this.#progress._timer) { 267 clearInterval(this.#progress._timer); 268 this.#progress._timer = null; 269 } 270 this.#controls.style.removeProperty('display'); 271 this.#progress.style.display = 'none'; 272 } 273 274 /** 275 * Display a message in the chat 276 * 277 * @param {string} message 278 * @param {object|null} sources Dict of sources {url:title, ...} if given this is assumed to be an AI message 279 * @returns {HTMLParagraphElement} Reference to the newly added message 280 */ 281 displayMessage(message, sources = null) { 282 const div = document.createElement('div'); 283 if(sources !== null) { 284 div.classList.add('ai'); 285 div.innerHTML = message; // we get HTML for AI messages 286 } else { 287 div.classList.add('user'); 288 div.textContent = message; 289 } 290 291 if (sources !== null && sources.length > 0) { 292 const ul = document.createElement('ul'); 293 sources.forEach((source) => { 294 const li = document.createElement('li'); 295 const a = document.createElement('a'); 296 a.href = source.url; 297 a.textContent = source.title; 298 a.title = `${source.page} (${source.score})`; 299 li.appendChild(a); 300 ul.appendChild(li); 301 }); 302 div.appendChild(ul); 303 } 304 305 this.#output.appendChild(div); 306 return div; 307 } 308 309 /** 310 * Send a question to the server 311 * 312 * @param {string} message 313 * @returns {Promise<object>} 314 */ 315 async sendMessage(message) { 316 const formData = new FormData(); 317 formData.append('question', message); 318 formData.append('history', JSON.stringify(this.#history)); 319 320 const response = await fetch(this.getAttribute('url') || '/', { 321 method: 'POST', 322 body: formData 323 }); 324 325 return await response.json(); 326 } 327} 328 329window.customElements.define('aichat-chat', AIChatChat); 330