xref: /plugin/aichat/script/AIChatChat.js (revision 72fdb01b36082e93d61fb18e56dbd6890c72e034)
1class AIChatChat extends HTMLElement {
2    #root = null;
3    #controls = null;
4    #input = null;
5    #output = null;
6    #progress = null;
7    #history = [];
8
9    constructor() {
10        super();
11
12        this.#root = this.attachShadow({mode: 'open'});
13        this.#root.innerHTML = `
14            <div class="output"></div>
15            <form>
16                <progress max="100" value="0"></progress>
17                <div class="controls">
18                    <button type="button" class="delete-history" title="restart">
19                        <svg viewBox="0 0 24 24"><path d="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" /></svg>
20                    </button>
21                    <textarea autofocus></textarea>
22                    <button type="submit" class="send" title="send">
23                        <svg viewBox="0 0 24 24"><path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" /></svg>
24                    </button>
25                </div>
26            </form>
27        `;
28        this.#root.appendChild(this.getStyle());
29        this.#input = this.#root.querySelector('textarea');
30        this.#output = this.#root.querySelector('.output');
31        this.#progress = this.#root.querySelector('progress');
32        this.#controls = this.#root.querySelector('.controls');
33        const form = this.#root.querySelector('form');
34        form.addEventListener('submit', this.onSubmit.bind(this));
35        const restart = this.#root.querySelector('.delete-history');
36        restart.addEventListener('click', this.deleteHistory.bind(this));
37        this.#input.addEventListener('keydown', (event) => {
38            if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
39                event.preventDefault();
40                this.onSubmit(event);
41            }
42        });
43    }
44
45    /**
46     * Called when the DOM has been connected
47     *
48     * We initialize the attribute based states here
49     */
50    connectedCallback() {
51        this.#output.innerHTML = '';
52        this.#input.placeholder = this.getAttribute('placeholder') || 'Your question...';
53        this.displayMessage(this.getAttribute('hello') || 'Hello, how can I help you?', {});
54
55        // make title attributes translatable
56        for (const elem of this.#root.querySelectorAll('[title]')) {
57            elem.title = this.getAttribute('title-'+elem.title) || elem.title;
58        }
59
60        this.restoreHistory();
61        this.stopProgress(); // initializes the visibility states
62    }
63
64    /**
65     * Define the web component's internal styles
66     *
67     * @returns {HTMLStyleElement}
68     */
69    getStyle() {
70        const style = document.createElement('style');
71        style.textContent = `
72            :host {
73                --color-human: #ebd8b2;
74                --color-ai: #c6dbf2;
75                --color-link: #4881bf;
76
77                display: flex;
78                flex-direction: column;
79                height: 100%;
80                justify-content: space-between;
81            }
82
83            * {
84                box-sizing: border-box;
85                font-family: sans-serif;
86            }
87            form {
88                clear: both;
89                margin-bottom: 1em;
90            }
91            .controls {
92                width: 100%;
93                position: relative;
94            }
95            .controls button {
96                padding: 0;
97                background: none;
98                border: none;
99                cursor: pointer;
100                display: flex;
101                width: 2.5em;
102
103                position: absolute;
104                bottom: 2px;
105                z-index: 1;
106            }
107            .controls button.delete-history {
108                left: 5px;
109            }
110            .controls button.send {
111                right: 5px;
112            }
113            .controls button svg {
114                flex-grow: 1;
115                flex-shrink: 1;
116                fill: var(--color-link);
117            }
118            .controls textarea {
119                width: 100%;
120                padding: 0.25em 3em;
121                font-size: 1.2em;
122                height: 4em;
123                border: none;
124                resize: vertical;
125            }
126            .controls textarea:focus {
127                outline: none;
128            }
129            progress{
130                width: 100%;
131            }
132            progress {
133                color: var(--color-link);
134                accent-color: var(--color-link);
135            }
136            a {
137                color: var(--color-link);
138            }
139            .output > div {
140                border-radius: 0.25em;
141                clear: both;
142                padding: 0.5em 1em;
143                position: relative;
144                margin-bottom: 1em;
145            }
146            .output > div::before {
147                content: "";
148                width: 0px;
149                height: 0px;
150                position: absolute;
151                top: 0;
152            }
153            .output > div.user {
154                background-color: var(--color-human);
155                float: right;
156                margin-right: 2em;
157                border-top-right-radius: 0;
158            }
159            .output > div.user::before {
160                right: -1em;
161                border-left: 0.5em solid var(--color-human);
162                border-right: 0.5em solid transparent;
163                border-top: 0.5em solid var(--color-human);
164                border-bottom: 0.5em solid transparent;
165            }
166            .output > div.ai {
167                background-color: var(--color-ai);
168                float: left;
169                margin-left: 2em;
170                border-top-left-radius: 0;
171            }
172            .output > div.ai::before {
173                left: -1em;
174                border-left: 0.5em solid transparent;
175                border-right: 0.5em solid var(--color-ai);
176                border-top: 0.5em solid var(--color-ai);
177                border-bottom: 0.5em solid transparent;
178            }
179        `;
180        return style;
181    }
182
183    /**
184     * Save history to session storage
185     */
186    saveHistory() {
187        sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history));
188    }
189
190    /**
191     * Load the history from session storage and display it
192     */
193    restoreHistory() {
194        const history = sessionStorage.getItem('ai-chat-history');
195        if (history) {
196            this.#history = JSON.parse(history);
197            this.#history.forEach(row => {
198                this.displayMessage(row[0]);
199                this.displayMessage(row[1], row[2]);
200            });
201        }
202    }
203
204    deleteHistory() {
205        sessionStorage.removeItem('ai-chat-history');
206        this.#history = [];
207        this.connectedCallback(); // re-initialize
208    }
209
210    /**
211     * Submit the given question to the server
212     *
213     * @param event
214     * @returns {Promise<void>}
215     */
216    async onSubmit(event) {
217        event.preventDefault();
218        const message = this.#input.value;
219        if (!message) return;
220
221        // show original question for now
222        const p = this.displayMessage(message);
223
224        this.#input.value = '';
225        this.startProgress();
226        try {
227            const response = await this.sendMessage(message);
228            this.#history.push([response.question, response.answer, response.sources]);
229            this.saveHistory();
230            p.textContent = response.question; // replace original question with interpretation
231            p.title = message; // show original question on hover
232            this.displayMessage(response.answer, response.sources); // display the answer
233        } catch (e) {
234            console.error(e);
235            this.displayMessage(LANG.plugins.aichat.error, {});
236        }
237
238        this.stopProgress();
239        this.#input.focus();
240        p.scrollIntoView();
241    }
242
243    /**
244     * Called when waiting for the response has started
245     *
246     * Hides the input field and shows the progress bar
247     */
248    startProgress() {
249        this.#controls.style.display = 'none';
250        this.#progress.style.display = 'block';
251        this.#progress.value = 0;
252
253        this.#progress._timer = setInterval(() => {
254            this.#progress.scrollIntoView();
255            const missing = this.#progress.max - this.#progress.value;
256            const add = missing / 100; // we will never reach 100%
257            this.#progress.value += add;
258        }, 100);
259    }
260
261    /**
262     * Called when waiting for the response has finished
263     *
264     * Resets the progress bar and shows the input field again
265     */
266    stopProgress() {
267        if (this.#progress._timer) {
268            clearInterval(this.#progress._timer);
269            this.#progress._timer = null;
270        }
271        this.#controls.style.removeProperty('display');
272        this.#progress.style.display = 'none';
273    }
274
275    /**
276     * Display a message in the chat
277     *
278     * @param {string} message
279     * @param {object|null} sources Dict of sources {url:title, ...}  if given this is assumed to be an AI message
280     * @returns {HTMLParagraphElement} Reference to the newly added message
281     */
282    displayMessage(message, sources = null) {
283        const div = document.createElement('div');
284        if(sources !== null) {
285            div.classList.add('ai');
286            div.innerHTML = message; // we get HTML for AI messages
287        } else {
288            div.classList.add('user');
289            div.textContent = message;
290        }
291
292        if (sources !== null && sources.length > 0) {
293            const ul = document.createElement('ul');
294            sources.forEach((source) => {
295                const li = document.createElement('li');
296                const a = document.createElement('a');
297                a.href = source.url;
298                a.textContent = source.title;
299                a.title = `${source.page} (${source.score})`;
300                li.appendChild(a);
301                ul.appendChild(li);
302            });
303            div.appendChild(ul);
304        }
305
306        this.#output.appendChild(div);
307        return div;
308    }
309
310    /**
311     * Send a question to the server
312     *
313     * @param {string} message
314     * @returns {Promise<object>}
315     */
316    async sendMessage(message) {
317        const formData = new FormData();
318        formData.append('question', message);
319        formData.append('history', JSON.stringify(this.#history));
320
321        const response = await fetch(this.getAttribute('url') || '/', {
322            method: 'POST',
323            body: formData
324        });
325
326        return await response.json();
327    }
328}
329
330window.customElements.define('aichat-chat', AIChatChat);
331