xref: /plugin/aichat/script/AIChatChat.js (revision 81b450c8469c053b1bd31ea13f5d6461d1f174e3)
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