class AIChatChat extends HTMLElement { #root = null; #controls = null; #input = null; #output = null; #progress = null; #history = []; constructor() { super(); this.#root = this.attachShadow({mode: 'open'}); this.#root.innerHTML = `
`; this.#root.appendChild(this.getStyle()); this.#input = this.#root.querySelector('textarea'); this.#output = this.#root.querySelector('.output'); this.#progress = this.#root.querySelector('progress'); this.#controls = this.#root.querySelector('.controls'); const form = this.#root.querySelector('form'); form.addEventListener('submit', this.onSubmit.bind(this)); const restart = this.#root.querySelector('.delete-history'); restart.addEventListener('click', this.deleteHistory.bind(this)); this.#input.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) { event.preventDefault(); form.dispatchEvent(new Event('submit')); } }); } /** * Called when the DOM has been connected * * We initialize the attribute based states here */ connectedCallback() { this.#input.placeholder = this.getAttribute('placeholder') || 'Your question...'; this.displayMessage(this.getAttribute('hello') || 'Hello, how can I help you?', {}); // make title attributes translatable for (const elem of this.#root.querySelectorAll('[title]')) { elem.title = this.getAttribute('title-'+elem.title) || elem.title; } this.restoreHistory(); this.stopProgress(); // initializes the visibility states } /** * Define the web component's internal styles * * @returns {HTMLStyleElement} */ getStyle() { const style = document.createElement('style'); style.textContent = ` :host { --color-human: #ebd8b2; --color-ai: #c6dbf2; --color-link: #4881bf; display: flex; flex-direction: column; height: 100%; justify-content: space-between; } * { box-sizing: border-box; font-family: sans-serif; } form { clear: both; margin-bottom: 1em; } .controls { width: 100%; position: relative; } .controls button { padding: 0; background: none; border: none; cursor: pointer; display: flex; width: 2.5em; position: absolute; bottom: 2px; z-index: 1; } .controls button.delete-history { left: 5px; } .controls button.send { right: 5px; } .controls button svg { flex-grow: 1; flex-shrink: 1; fill: var(--color-link); } .controls textarea { width: 100%; padding: 0.25em 3em; font-size: 1.2em; height: 4em; border: none; resize: vertical; } .controls textarea:focus { outline: none; } progress{ width: 100%; } progress { color: var(--color-link); accent-color: var(--color-link); } a { color: var(--color-link); } .output > div { border-radius: 0.25em; clear: both; padding: 0.5em 1em; position: relative; margin-bottom: 1em; } .output > div::before { content: ""; width: 0px; height: 0px; position: absolute; top: 0; } .output > div.user { background-color: var(--color-human); float: right; margin-right: 2em; border-top-right-radius: 0; } .output > div.user::before { right: -1em; border-left: 0.5em solid var(--color-human); border-right: 0.5em solid transparent; border-top: 0.5em solid var(--color-human); border-bottom: 0.5em solid transparent; } .output > div.ai { background-color: var(--color-ai); float: left; margin-left: 2em; border-top-left-radius: 0; } .output > div.ai::before { left: -1em; border-left: 0.5em solid transparent; border-right: 0.5em solid var(--color-ai); border-top: 0.5em solid var(--color-ai); border-bottom: 0.5em solid transparent; } `; return style; } /** * Save history to session storage */ saveHistory() { sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history)); } /** * Load the history from session storage and display it */ restoreHistory() { const history = sessionStorage.getItem('ai-chat-history'); if (history) { this.#history = JSON.parse(history); this.#history.forEach(row => { this.displayMessage(row[0]); this.displayMessage(row[1], row[2]); }); } } deleteHistory() { sessionStorage.removeItem('ai-chat-history'); this.#history = []; this.#output.innerHTML = ''; this.connectedCallback(); // re-initialize } /** * Submit the given question to the server * * @param event * @returns {Promise} */ async onSubmit(event) { event.preventDefault(); const message = this.#input.value; if (!message) return; // show original question for now const p = this.displayMessage(message); this.#input.value = ''; this.startProgress(); try { const response = await this.sendMessage(message); this.#history.push([response.question, response.answer, response.sources]); this.saveHistory(); p.textContent = response.question; // replace original question with interpretation p.title = message; // show original question on hover this.displayMessage(response.answer, response.sources); // display the answer } catch (e) { console.error(e); this.displayMessage('Sorry, something went wrong', {}); } this.stopProgress(); this.#input.focus(); } /** * Called when waiting for the response has started * * Hides the input field and shows the progress bar */ startProgress() { this.#controls.style.display = 'none'; this.#progress.style.display = 'block'; this.#progress.value = 0; this.#progress._timer = setInterval(() => { this.#progress.scrollIntoView(); const missing = this.#progress.max - this.#progress.value; const add = missing / 100; // we will never reach 100% this.#progress.value += add; }, 100); } /** * Called when waiting for the response has finished * * Resets the progress bar and shows the input field again */ stopProgress() { if (this.#progress._timer) { clearInterval(this.#progress._timer); this.#progress._timer = null; } this.#controls.style.removeProperty('display'); this.#progress.style.display = 'none'; } /** * Display a message in the chat * * @param {string} message * @param {object|null} sources Dict of sources {url:title, ...} if given this is assumed to be an AI message * @returns {HTMLParagraphElement} Reference to the newly added message */ displayMessage(message, sources = null) { const div = document.createElement('div'); if(sources !== null) { div.classList.add('ai'); div.innerHTML = message; // we get HTML for AI messages } else { div.classList.add('user'); div.textContent = message; } if (sources !== null && sources.length > 0) { const ul = document.createElement('ul'); sources.forEach((source) => { const li = document.createElement('li'); const a = document.createElement('a'); a.href = source.url; a.textContent = source.title; a.title = `${source.page} (${source.score})`; li.appendChild(a); ul.appendChild(li); }); div.appendChild(ul); } this.#output.appendChild(div); return div; } /** * Send a question to the server * * @param {string} message * @returns {Promise} */ async sendMessage(message) { const formData = new FormData(); formData.append('question', message); formData.append('history', JSON.stringify(this.#history)); const response = await fetch(this.getAttribute('url') || '/', { method: 'POST', body: formData }); return await response.json(); } } window.customElements.define('aichat-chat', AIChatChat);