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