PageSourceSearch

https://www.gcimplantdental.com.au/js/gpw-chat-widget.js

js gcimplantdental.com.au collected 2026-10-08 02:04:01 UTC 13,110 bytes, 346 lines download raw bytes

1/**
2 * GPW Chat Widget
3 * AI-powered dental health assistant chat widget
4 */
5(function() {
6    'use strict';
7
8    // Widget state
9    var state = {
10        isOpen: false,
11        isLoading: false,
12        messages: []
13    };
14
15    // DOM elements (populated on init)
16    var elements = {};
17
18    // Configuration
19    var config = {
20        // FW/1 route - uses main controller chatapi action
21        endpoint: '/main/chatapi',
22        welcomeMessage: "Hi! I'm here to help answer your dental health questions. What would you like to know?",
23        placeholderText: "Type your question here...",
24        errorMessage: "Sorry, I couldn't process your request. Please try again."
25    };
26
27    /**
28     * Initialize the chat widget
29     */
30    function init() {
31        createWidgetHTML();
32        bindEvents();
33        addWelcomeMessage();
34    }
35
36    /**
37     * Create the widget HTML structure
38     */
39    function createWidgetHTML() {
40        var widget = document.createElement('div');
41        widget.id = 'gpw-chat-widget';
42        widget.innerHTML = `
43            <button id="gpw-chat-toggle" class="gpw-chat-toggle" aria-label="Open chat assistant" title="Chat with us">
44                <svg class="gpw-chat-icon-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
45                    <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
46                </svg>
47                <svg class="gpw-chat-icon-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
48                    <line x1="18" y1="6" x2="6" y2="18"></line>
49                    <line x1="6" y1="6" x2="18" y2="18"></line>
50                </svg>
51            </button>
52            <div id="gpw-chat-panel" class="gpw-chat-panel" aria-hidden="true">
53                <div class="gpw-chat-header">
54                    <div class="gpw-chat-header-content">
55                        <svg class="gpw-chat-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
56                            <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
57                        </svg>
58                        <span>Dental Health Assistant</span>
59                    </div>
60                    <button class="gpw-chat-minimize" aria-label="Minimize chat">
61                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
62                            <line x1="5" y1="12" x2="19" y2="12"></line>
63                        </svg>
64                    </button>
65                </div>
66                <div id="gpw-chat-messages" class="gpw-chat-messages" role="log" aria-live="polite">
67                </div>
68                <div class="gpw-chat-input-container">
69                    <form id="gpw-chat-form" class="gpw-chat-form">
70                        <input 
71                            type="text" 
72                            id="gpw-chat-input" 
73                            class="gpw-chat-input" 
74                            placeholder="${config.placeholderText}"
75                            autocomplete="off"
76                            maxlength="1000"
77                            aria-label="Type your message"
78                        >
79                        <button type="submit" id="gpw-chat-submit" class="gpw-chat-submit" aria-label="Send message">
80                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
81                                <line x1="22" y1="2" x2="11" y2="13"></line>
82                                <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
83                            </svg>
84                        </button>
85                    </form>
86                    <div class="gpw-chat-disclaimer">
87                        <small>AI-powered assistant. Not a substitute for professional advice.</small>
88                    </div>
89                </div>
90            </div>
91        `;
92
93        // Apply position class from site setting (passed via data-position on container div)
94        var container = document.getElementById('chat-widget-container');
95        var position = (container && container.getAttribute('data-position')) || 'bottom-right';
96        widget.classList.add('position-' + position);
97
98        document.body.appendChild(widget);
99
100        // Store element references
101        elements.widget = widget;
102        elements.toggle = document.getElementById('gpw-chat-toggle');
103        elements.panel = document.getElementById('gpw-chat-panel');
104        elements.messages = document.getElementById('gpw-chat-messages');
105        elements.form = document.getElementById('gpw-chat-form');
106        elements.input = document.getElementById('gpw-chat-input');
107        elements.submit = document.getElementById('gpw-chat-submit');
108        elements.minimize = widget.querySelector('.gpw-chat-minimize');
109    }
110
111    /**
112     * Bind event listeners
113     */
114    function bindEvents() {
115        elements.toggle.addEventListener('click', toggleChat);
116        elements.minimize.addEventListener('click', toggleChat);
117        elements.form.addEventListener('submit', handleSubmit);
118        
119        // Close on escape key
120        document.addEventListener('keydown', function(e) {
121            if (e.key === 'Escape' && state.isOpen) {
122                toggleChat();
123            }
124        });
125    }
126
127    /**
128     * Toggle chat panel open/closed
129     */
130    function toggleChat() {
131        state.isOpen = !state.isOpen;
132        
133        elements.widget.classList.toggle('gpw-chat-open', state.isOpen);
134        elements.panel.setAttribute('aria-hidden', !state.isOpen);
135        elements.toggle.setAttribute('aria-expanded', state.isOpen);
136        
137        if (state.isOpen) {
138            elements.input.focus();
139        }
140    }
141
142    /**
143     * Add welcome message on first open
144     */
145    function addWelcomeMessage() {
146        addMessage('assistant', config.welcomeMessage);
147    }
148
149    /**
150     * Handle form submission
151     */
152    function handleSubmit(e) {
153        e.preventDefault();
154
155        var question = elements.input.value.trim();
156        if (!question || state.isLoading) return;
157
158        // Add user message to chat
159        addMessage('user', question);
160        elements.input.value = '';
161
162        // Show loading state
163        setLoading(true);
164
165        // Send to API
166        sendMessage(question);
167    }
168
169    /**
170     * Send message to API
171     */
172    function sendMessage(question) {
173        var xhr = new XMLHttpRequest();
174        xhr.open('POST', config.endpoint, true);
175        xhr.setRequestHeader('Content-Type', 'application/json');
176        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
177
178        xhr.onreadystatechange = function() {
179            if (xhr.readyState === 4) {
180                setLoading(false);
181
182                if (xhr.status === 200) {
183                    try {
184                        var response = JSON.parse(xhr.responseText);
185                        if (response.success) {
186                            addMessage('assistant', response.answer, response.suggestedLinks);
187                        } else {
188                            addMessage('assistant', response.error || config.errorMessage);
189                        }
190                    } catch (e) {
191                        addMessage('assistant', config.errorMessage);
192                    }
193                } else {
194                    addMessage('assistant', config.errorMessage);
195                }
196            }
197        };
198
199        xhr.onerror = function() {
200            setLoading(false);
201            addMessage('assistant', config.errorMessage);
202        };
203
204        xhr.send(JSON.stringify({ question: question }));
205    }
206
207    /**
208     * Add a message to the chat display
209     */
210    function addMessage(role, content, links) {
211        var message = {
212            role: role,
213            content: content,
214            links: links || [],
215            timestamp: new Date()
216        };
217        state.messages.push(message);
218
219        var messageEl = document.createElement('div');
220        messageEl.className = 'gpw-chat-message gpw-chat-message-' + role;
221        
222        // Process content - convert markdown links to HTML
223        var processedContent = processMessageContent(content);
224        
225        var bubble = document.createElement('div');
226        bubble.className = 'gpw-chat-bubble';
227        bubble.innerHTML = processedContent;
228        messageEl.appendChild(bubble);
229
230        // Add suggested links if provided and not already in content
231        if (links && links.length > 0) {
232            var linksEl = document.createElement('div');
233            linksEl.className = 'gpw-chat-links';
234            linksEl.innerHTML = '<span class="gpw-chat-links-label">Related pages:</span>';
235            
236            var linksList = document.createElement('div');
237            linksList.className = 'gpw-chat-links-list';
238            
239            links.forEach(function(link) {
240                var linkEl = document.createElement('a');
241                linkEl.href = link.url;
242                linkEl.className = 'gpw-chat-link';
243                linkEl.textContent = link.title;
244                linkEl.setAttribute('target', '_blank');
245                linkEl.setAttribute('rel', 'noopener');
246                linksList.appendChild(linkEl);
247            });
248            
249            linksEl.appendChild(linksList);
250            messageEl.appendChild(linksEl);
251        }
252
253        elements.messages.appendChild(messageEl);
254        
255        // Scroll to bottom
256        elements.messages.scrollTop = elements.messages.scrollHeight;
257    }
258
259    /**
260     * Process message content - convert a small subset of markdown to HTML.
261     *
262     * Claude's system prompt asks for plain prose with links only, but it
263     * still occasionally emits **bold** or *italic* — left unrendered, those
264     * asterisks leak into the bubble as literal characters. We handle them
265     * here so the formatting degrades gracefully either way.
266     *
267     * Order matters:
268     *   1. HTML-escape first so tags in the model output are inert.
269     *   2. Links second (the bracket/paren chars survived escaping).
270     *   3. Bold (**) before italic (*) so a "**word**" run isn't picked
271     *      apart by the italic pattern.
272     *   4. Newlines last so we don't break <a>/<strong>/<em> tags across <br>.
273     */
274    function processMessageContent(content) {
275        // 1. Escape HTML first
276        var escaped = content
277            .replace(/&/g, '&amp;')
278            .replace(/</g, '&lt;')
279            .replace(/>/g, '&gt;');
280
281        // 2. Convert markdown links [text](url) to HTML
282        escaped = escaped.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, function(match, text, url) {
283            // Validate URL is from our domain for security
284            if (url.indexOf(window.location.hostname) !== -1 || url.charAt(0) === '/') {
285                return '<a href="' + url + '" target="_blank" rel="noopener" class="gpw-chat-inline-link">
285' + text + '</a>';
286            }
287            return text;
288        });
289
290        // 3a. Bold (**text**) -> <strong>. [^*\n] prevents the pattern from
291        // spanning newlines or eating adjacent runs of asterisks.
292        escaped = escaped.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
293
294        // 3b. Italic (*text*) -> <em>. Same single-line, non-asterisk
295        // constraint. Bare asterisks (e.g. "5 * 5") survive untouched
296        // because they don't form a matched pair on the same line.
297        escaped = escaped.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
298
299        // 4. Convert line breaks to <br>
300        escaped = escaped.replace(/\n/g, '<br>');
301
302        return escaped;
303    }
304
305    /**
306     * Set loading state
307     */
308    function setLoading(loading) {
309        state.isLoading = loading;
310        elements.widget.classList.toggle('gpw-chat-loading', loading);
311        elements.submit.disabled = loading;
312        elements.input.disabled = loading;
313
314        if (loading) {
315            // Add loading indicator
316            var loadingEl = document.createElement('div');
317            loadingEl.className = 'gpw-chat-message gpw-chat-message-assistant gpw-chat-message-loading';
318            loadingEl.innerHTML = `
319                <div class="gpw-chat-bubble">
320                    <div class="gpw-chat-typing">
321                        <span></span>
322                        <span></span>
323                        <span></span>
324                    </div>
325                </div>
326            `;
327            loadingEl.id = 'gpw-chat-loading';
328            elements.messages.appendChild(loadingEl);
329            elements.messages.scrollTop = elements.messages.scrollHeight;
330        } else {
331            // Remove loading indicator
332            var loadingIndicator = document.getElementById('gpw-chat-loading');
333            if (loadingIndicator) {
334                loadingIndicator.remove();
335            }
336        }
337    }
338
339    // Initialize when DOM is ready
340    if (document.readyState === 'loading') {
341        document.addEventListener('DOMContentLoaded', init);
342    } else {
343        init();
344    }
345
346})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.