xref: /plugin/aichat/script/AIChatChat.js (revision 614f8ab4ac738aed3502238736999a25e6cf719a)
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            p.title = message; // show original question on hover
174            this.displayMessage(response.answer, response.sources); // display the answer
175        } catch (e) {
176            console.error(e);
177            this.displayMessage('Sorry, something went wrong', {});
178        }
179
180        this.stopProgress();
181        this.#input.focus();
182    }
183
184    /**
185     * Called when waiting for the response has started
186     *
187     * Hides the input field and shows the progress bar
188     */
189    startProgress() {
190        this.#input.style.display = 'none';
191        this.#progress.style.display = 'block';
192        this.#progress.value = 0;
193
194        this.#progress._timer = setInterval(() => {
195            this.#progress.scrollIntoView();
196            const missing = this.#progress.max - this.#progress.value;
197            const add = missing / 100; // we will never reach 100%
198            this.#progress.value += add;
199        }, 100);
200    }
201
202    /**
203     * Called when waiting for the response has finished
204     *
205     * Resets the progress bar and shows the input field again
206     */
207    stopProgress() {
208        if (this.#progress._timer) {
209            clearInterval(this.#progress._timer);
210            this.#progress._timer = null;
211        }
212        this.#input.style.display = 'initial';
213        this.#progress.style.display = 'none';
214    }
215
216    /**
217     * Display a message in the chat
218     *
219     * @param {string} message
220     * @param {object|null} sources Dict of sources {url:title, ...}  if given this is assumed to be an AI message
221     * @returns {HTMLParagraphElement} Reference to the newly added message
222     */
223    displayMessage(message, sources = null) {
224        const p = document.createElement('p');
225        p.textContent = message;
226        p.classList.add(sources !== null ? 'ai' : 'user');
227
228        if (sources !== null && Object.keys(sources).length > 0) {
229            const ul = document.createElement('ul');
230            Object.entries(sources).forEach(([url, title]) => {
231                const li = document.createElement('li');
232                const a = document.createElement('a');
233                a.href = url;
234                a.textContent = title;
235                li.appendChild(a);
236                ul.appendChild(li);
237            });
238            p.appendChild(ul);
239        }
240
241        this.#output.appendChild(p);
242        return p;
243    }
244
245    /**
246     * Send a question to the server
247     *
248     * @param {string} message
249     * @returns {Promise<object>}
250     */
251    async sendMessage(message) {
252        const formData = new FormData();
253        formData.append('question', message);
254        formData.append('history', JSON.stringify(this.#history));
255
256        const response = await fetch(this.getAttribute('url') || '/', {
257            method: 'POST',
258            body: formData
259        });
260
261        return await response.json();
262    }
263}
264
265window.customElements.define('aichat-chat', AIChatChat);
266