xref: /plugin/aichat/script/AIChatChat.js (revision 3379af09b7ec10f96a8d4f23b1563bd7f9ae79ac)
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 Conversation">
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                    <input type="text" autofocus />
22                </div>
23            </form>
24        `;
25        this.#root.appendChild(this.getStyle());
26        this.#input = this.#root.querySelector('input');
27        this.#output = this.#root.querySelector('.output');
28        this.#progress = this.#root.querySelector('progress');
29        this.#controls = this.#root.querySelector('.controls');
30        const form = this.#root.querySelector('form');
31        form.addEventListener('submit', this.onSubmit.bind(this));
32        const restart = this.#root.querySelector('.delete-history');
33        restart.addEventListener('click', this.deleteHistory.bind(this));
34    }
35
36    /**
37     * Called when the DOM has been connected
38     *
39     * We initialize the attribute based states here
40     */
41    connectedCallback() {
42        this.#input.placeholder = this.getAttribute('placeholder') || 'Your question...';
43        this.displayMessage(this.getAttribute('hello') || 'Hello, how can I help you?', {});
44        this.restoreHistory();
45        this.stopProgress(); // initializes the visibility states
46    }
47
48    /**
49     * Define the web component's internal styles
50     *
51     * @returns {HTMLStyleElement}
52     */
53    getStyle() {
54        const style = document.createElement('style');
55        style.textContent = `
56            :host {
57                --color-human: #ebd8b2;
58                --color-ai: #c6dbf2;
59                --color-link: #4881bf;
60
61                display: flex;
62                flex-direction: column;
63                height: 100%;
64                justify-content: space-between;
65            }
66
67            * {
68                box-sizing: border-box;
69                font-family: sans-serif;
70            }
71            form {
72                clear: both;
73                margin-bottom: 1em;
74            }
75            .controls {
76                display: flex;
77                width: 100%;
78            }
79            .controls button {
80                padding: 0;
81                background: none;
82                border: none;
83                cursor: pointer;
84                display: flex;
85                width: 3em;
86            }
87            .controls button svg {
88                flex-grow: 1;
89                flex-shrink: 1;
90                fill: var(--color-link);
91            }
92            .controls input {
93                flex-grow: 1;
94                padding: 0.25em;
95                font-size: 1.2em;
96            }
97            progress{
98                width: 100%;
99            }
100            progress {
101                color: var(--color-link);
102                accent-color: var(--color-link);
103            }
104            a {
105                color: var(--color-link);
106            }
107            .output > div {
108                border-radius: 0.25em;
109                clear: both;
110                padding: 0.5em 1em;
111                position: relative;
112                margin-bottom: 1em;
113            }
114            .output > div::before {
115                content: "";
116                width: 0px;
117                height: 0px;
118                position: absolute;
119                top: 0;
120            }
121            .output > div.user {
122                background-color: var(--color-human);
123                float: right;
124                margin-right: 2em;
125                border-top-right-radius: 0;
126            }
127            .output > div.user::before {
128                right: -1em;
129                border-left: 0.5em solid var(--color-human);
130                border-right: 0.5em solid transparent;
131                border-top: 0.5em solid var(--color-human);
132                border-bottom: 0.5em solid transparent;
133            }
134            .output > div.ai {
135                background-color: var(--color-ai);
136                float: left;
137                margin-left: 2em;
138                border-top-left-radius: 0;
139            }
140            .output > div.ai::before {
141                left: -1em;
142                border-left: 0.5em solid transparent;
143                border-right: 0.5em solid var(--color-ai);
144                border-top: 0.5em solid var(--color-ai);
145                border-bottom: 0.5em solid transparent;
146            }
147        `;
148        return style;
149    }
150
151    /**
152     * Save history to session storage
153     */
154    saveHistory() {
155        sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history));
156    }
157
158    /**
159     * Load the history from session storage and display it
160     */
161    restoreHistory() {
162        const history = sessionStorage.getItem('ai-chat-history');
163        if (history) {
164            this.#history = JSON.parse(history);
165            this.#history.forEach(row => {
166                this.displayMessage(row[0]);
167                this.displayMessage(row[1], row[2]);
168            });
169        }
170    }
171
172    deleteHistory() {
173        sessionStorage.removeItem('ai-chat-history');
174        this.#history = [];
175        this.#output.innerHTML = '';
176        this.connectedCallback(); // re-initialize
177    }
178
179    /**
180     * Submit the given question to the server
181     *
182     * @param event
183     * @returns {Promise<void>}
184     */
185    async onSubmit(event) {
186        event.preventDefault();
187        const message = this.#input.value;
188        if (!message) return;
189
190        // show original question for now
191        const p = this.displayMessage(message);
192
193        this.#input.value = '';
194        this.startProgress();
195        try {
196            const response = await this.sendMessage(message);
197            this.#history.push([response.question, response.answer, response.sources]);
198            this.saveHistory();
199            p.textContent = response.question; // replace original question with interpretation
200            p.title = message; // show original question on hover
201            this.displayMessage(response.answer, response.sources); // display the answer
202        } catch (e) {
203            console.error(e);
204            this.displayMessage('Sorry, something went wrong', {});
205        }
206
207        this.stopProgress();
208        this.#input.focus();
209    }
210
211    /**
212     * Called when waiting for the response has started
213     *
214     * Hides the input field and shows the progress bar
215     */
216    startProgress() {
217        this.#controls.style.display = 'none';
218        this.#progress.style.display = 'block';
219        this.#progress.value = 0;
220
221        this.#progress._timer = setInterval(() => {
222            this.#progress.scrollIntoView();
223            const missing = this.#progress.max - this.#progress.value;
224            const add = missing / 100; // we will never reach 100%
225            this.#progress.value += add;
226        }, 100);
227    }
228
229    /**
230     * Called when waiting for the response has finished
231     *
232     * Resets the progress bar and shows the input field again
233     */
234    stopProgress() {
235        if (this.#progress._timer) {
236            clearInterval(this.#progress._timer);
237            this.#progress._timer = null;
238        }
239        this.#controls.style.removeProperty('display');
240        this.#progress.style.display = 'none';
241    }
242
243    /**
244     * Display a message in the chat
245     *
246     * @param {string} message
247     * @param {object|null} sources Dict of sources {url:title, ...}  if given this is assumed to be an AI message
248     * @returns {HTMLParagraphElement} Reference to the newly added message
249     */
250    displayMessage(message, sources = null) {
251        const div = document.createElement('div');
252        if(sources !== null) {
253            div.classList.add('ai');
254            div.innerHTML = message; // we get HTML for AI messages
255        } else {
256            div.classList.add('user');
257            div.textContent = message;
258        }
259
260        if (sources !== null && Object.keys(sources).length > 0) {
261            const ul = document.createElement('ul');
262            Object.entries(sources).forEach(([url, title]) => {
263                const li = document.createElement('li');
264                const a = document.createElement('a');
265                a.href = url;
266                a.textContent = title;
267                li.appendChild(a);
268                ul.appendChild(li);
269            });
270            div.appendChild(ul);
271        }
272
273        this.#output.appendChild(div);
274        return div;
275    }
276
277    /**
278     * Send a question to the server
279     *
280     * @param {string} message
281     * @returns {Promise<object>}
282     */
283    async sendMessage(message) {
284        const formData = new FormData();
285        formData.append('question', message);
286        formData.append('history', JSON.stringify(this.#history));
287
288        const response = await fetch(this.getAttribute('url') || '/', {
289            method: 'POST',
290            body: formData
291        });
292
293        return await response.json();
294    }
295}
296
297window.customElements.define('aichat-chat', AIChatChat);
298