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