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 fill: var(--color-link); 91 } 92 .controls input { 93 flex-grow: 1; 94 padding: 0.25em; 95 font-size: 1.2em; 96 } 97 progress{ 98 width: 100%; 99 } 100 progress { 101 color: var(--color-link); 102 accent-color: var(--color-link); 103 } 104 a { 105 color: var(--color-link); 106 } 107 .output > div { 108 border-radius: 0.25em; 109 clear: both; 110 padding: 0.5em 1em; 111 position: relative; 112 margin-bottom: 1em; 113 } 114 .output > div::before { 115 content: ""; 116 width: 0px; 117 height: 0px; 118 position: absolute; 119 top: 0; 120 } 121 .output > div.user { 122 background-color: var(--color-human); 123 float: right; 124 margin-right: 2em; 125 border-top-right-radius: 0; 126 } 127 .output > div.user::before { 128 right: -1em; 129 border-left: 0.5em solid var(--color-human); 130 border-right: 0.5em solid transparent; 131 border-top: 0.5em solid var(--color-human); 132 border-bottom: 0.5em solid transparent; 133 } 134 .output > div.ai { 135 background-color: var(--color-ai); 136 float: left; 137 margin-left: 2em; 138 border-top-left-radius: 0; 139 } 140 .output > div.ai::before { 141 left: -1em; 142 border-left: 0.5em solid transparent; 143 border-right: 0.5em solid var(--color-ai); 144 border-top: 0.5em solid var(--color-ai); 145 border-bottom: 0.5em solid transparent; 146 } 147 `; 148 return style; 149 } 150 151 /** 152 * Save history to session storage 153 */ 154 saveHistory() { 155 sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history)); 156 } 157 158 /** 159 * Load the history from session storage and display it 160 */ 161 restoreHistory() { 162 const history = sessionStorage.getItem('ai-chat-history'); 163 if (history) { 164 this.#history = JSON.parse(history); 165 this.#history.forEach(row => { 166 this.displayMessage(row[0]); 167 this.displayMessage(row[1], row[2]); 168 }); 169 } 170 } 171 172 deleteHistory() { 173 sessionStorage.removeItem('ai-chat-history'); 174 this.#history = []; 175 this.#output.innerHTML = ''; 176 this.connectedCallback(); // re-initialize 177 } 178 179 /** 180 * Submit the given question to the server 181 * 182 * @param event 183 * @returns {Promise<void>} 184 */ 185 async onSubmit(event) { 186 event.preventDefault(); 187 const message = this.#input.value; 188 if (!message) return; 189 190 // show original question for now 191 const p = this.displayMessage(message); 192 193 this.#input.value = ''; 194 this.startProgress(); 195 try { 196 const response = await this.sendMessage(message); 197 this.#history.push([response.question, response.answer, response.sources]); 198 this.saveHistory(); 199 p.textContent = response.question; // replace original question with interpretation 200 p.title = message; // show original question on hover 201 this.displayMessage(response.answer, response.sources); // display the answer 202 } catch (e) { 203 console.error(e); 204 this.displayMessage('Sorry, something went wrong', {}); 205 } 206 207 this.stopProgress(); 208 this.#input.focus(); 209 } 210 211 /** 212 * Called when waiting for the response has started 213 * 214 * Hides the input field and shows the progress bar 215 */ 216 startProgress() { 217 this.#controls.style.display = 'none'; 218 this.#progress.style.display = 'block'; 219 this.#progress.value = 0; 220 221 this.#progress._timer = setInterval(() => { 222 this.#progress.scrollIntoView(); 223 const missing = this.#progress.max - this.#progress.value; 224 const add = missing / 100; // we will never reach 100% 225 this.#progress.value += add; 226 }, 100); 227 } 228 229 /** 230 * Called when waiting for the response has finished 231 * 232 * Resets the progress bar and shows the input field again 233 */ 234 stopProgress() { 235 if (this.#progress._timer) { 236 clearInterval(this.#progress._timer); 237 this.#progress._timer = null; 238 } 239 this.#controls.style.removeProperty('display'); 240 this.#progress.style.display = 'none'; 241 } 242 243 /** 244 * Display a message in the chat 245 * 246 * @param {string} message 247 * @param {object|null} sources Dict of sources {url:title, ...} if given this is assumed to be an AI message 248 * @returns {HTMLParagraphElement} Reference to the newly added message 249 */ 250 displayMessage(message, sources = null) { 251 const div = document.createElement('div'); 252 if(sources !== null) { 253 div.classList.add('ai'); 254 div.innerHTML = message; // we get HTML for AI messages 255 } else { 256 div.classList.add('user'); 257 div.textContent = message; 258 } 259 260 if (sources !== null && Object.keys(sources).length > 0) { 261 const ul = document.createElement('ul'); 262 Object.entries(sources).forEach(([url, title]) => { 263 const li = document.createElement('li'); 264 const a = document.createElement('a'); 265 a.href = url; 266 a.textContent = title; 267 li.appendChild(a); 268 ul.appendChild(li); 269 }); 270 div.appendChild(ul); 271 } 272 273 this.#output.appendChild(div); 274 return div; 275 } 276 277 /** 278 * Send a question to the server 279 * 280 * @param {string} message 281 * @returns {Promise<object>} 282 */ 283 async sendMessage(message) { 284 const formData = new FormData(); 285 formData.append('question', message); 286 formData.append('history', JSON.stringify(this.#history)); 287 288 const response = await fetch(this.getAttribute('url') || '/', { 289 method: 'POST', 290 body: formData 291 }); 292 293 return await response.json(); 294 } 295} 296 297window.customElements.define('aichat-chat', AIChatChat); 298