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