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, '&') 278 .replace(/</g, '<') 279 .replace(/>/g, '>'); 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.