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