xref: /plugin/aichat/script/AIChatChat.js (revision ad38c5fd62a65d04772bdd994d54a93483f88639)
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            }
73            progress,
74            input {
75                width: 100%;
76            }
77            input {
78                padding: 0.25em;
79                font-size: 1.2em;
80            }
81            progress {
82                color: var(--color-link);
83                accent-color: var(--color-link);
84            }
85            a {
86                color: var(--color-link);
87            }
88            .output p {
89                border-radius: 0.25em;
90                clear: both;
91                padding: 0.5em 1em;
92                position: relative;
93            }
94            .output p::before {
95                content: "";
96                width: 0px;
97                height: 0px;
98                position: absolute;
99                top: 0;
100            }
101            .output p.user {
102                background-color: var(--color-human);
103                float: right;
104                margin-right: 2em;
105                border-top-right-radius: 0;
106            }
107            .output p.user::before {
108                right: -1em;
109                border-left: 0.5em solid var(--color-human);
110                border-right: 0.5em solid transparent;
111                border-top: 0.5em solid var(--color-human);
112                border-bottom: 0.5em solid transparent;
113            }
114            .output p.ai {
115                background-color: var(--color-ai);
116                float: left;
117                margin-left: 2em;
118                border-top-left-radius: 0;
119            }
120            .output p.ai::before {
121                left: -1em;
122                border-left: 0.5em solid transparent;
123                border-right: 0.5em solid var(--color-ai);
124                border-top: 0.5em solid var(--color-ai);
125                border-bottom: 0.5em solid transparent;
126            }
127        `;
128        return style;
129    }
130
131    /**
132     * Save history to session storage
133     */
134    saveHistory() {
135        sessionStorage.setItem('ai-chat-history', JSON.stringify(this.#history));
136    }
137
138    /**
139     * Load the history from session storage and display it
140     */
141    restoreHistory() {
142        const history = sessionStorage.getItem('ai-chat-history');
143        if (history) {
144            this.#history = JSON.parse(history);
145            this.#history.forEach(row => {
146                this.displayMessage(row[0]);
147                this.displayMessage(row[1], row[2]);
148            });
149        }
150    }
151
152    /**
153     * Submit the given question to the server
154     *
155     * @param event
156     * @returns {Promise<void>}
157     */
158    async onSubmit(event) {
159        event.preventDefault();
160        const message = this.#input.value;
161        if (!message) return;
162
163        // show original question for now
164        const p = this.displayMessage(message);
165
166        this.#input.value = '';
167        this.startProgress();
168        try {
169            const response = await this.sendMessage(message);
170            this.#history.push([response.question, response.answer, response.sources]);
171            this.saveHistory();
172            p.textContent = response.question; // replace original question with interpretation
173            this.displayMessage(response.answer, response.sources); // display the answer
174        } catch (e) {
175            console.error(e);
176            this.displayMessage('Sorry, something went wrong', {});
177        }
178
179        this.stopProgress();
180    }
181
182    /**
183     * Called when waiting for the response has started
184     *
185     * Hides the input field and shows the progress bar
186     */
187    startProgress() {
188        this.#input.style.display = 'none';
189        this.#progress.style.display = 'block';
190        this.#progress.value = 0;
191
192        this.#progress._timer = setInterval(() => {
193            this.#progress.scrollIntoView();
194            const missing = this.#progress.max - this.#progress.value;
195            const add = missing / 100; // we will never reach 100%
196            this.#progress.value += add;
197        }, 100);
198    }
199
200    /**
201     * Called when waiting for the response has finished
202     *
203     * Resets the progress bar and shows the input field again
204     */
205    stopProgress() {
206        if (this.#progress._timer) {
207            clearInterval(this.#progress._timer);
208            this.#progress._timer = null;
209        }
210        this.#input.style.display = 'initial';
211        this.#progress.style.display = 'none';
212        this.#input.focus();
213    }
214
215    /**
216     * Display a message in the chat
217     *
218     * @param {string} message
219     * @param {object|null} sources Dict of sources {url:title, ...}  if given this is assumed to be an AI message
220     * @returns {HTMLParagraphElement} Reference to the newly added message
221     */
222    displayMessage(message, sources = null) {
223        const p = document.createElement('p');
224        p.textContent = message;
225        p.classList.add(sources !== null ? 'ai' : 'user');
226
227        if (sources !== null && Object.keys(sources).length > 0) {
228            const ul = document.createElement('ul');
229            Object.entries(sources).forEach(([url, title]) => {
230                const li = document.createElement('li');
231                const a = document.createElement('a');
232                a.href = url;
233                a.textContent = title;
234                li.appendChild(a);
235                ul.appendChild(li);
236            });
237            p.appendChild(ul);
238        }
239
240        this.#output.appendChild(p);
241        return p;
242    }
243
244    /**
245     * Send a question to the server
246     *
247     * @param {string} message
248     * @returns {Promise<object>}
249     */
250    async sendMessage(message) {
251        const formData = new FormData();
252        formData.append('question', message);
253        formData.append('history', JSON.stringify(this.#history));
254
255        const response = await fetch(this.getAttribute('url') || '/', {
256            method: 'POST',
257            body: formData
258        });
259
260        return await response.json();
261    }
262}
263
264window.customElements.define('aichat-chat', AIChatChat);
265