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