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