PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 All 138 releases
wpforo / assets / js / ai-chatbot.js

ai-chatbot.js in wpForo Forum 3.1.6, at assets/js/ai-chatbot.js

663 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* global wpforo, $wpf, wpforo_phrase, wpforo_load_hide */
2 /**
3 * wpForo AI Chatbot JavaScript
4 *
5 * AI-powered chat functionality for the AI Assistant widget
6 * Requires: frontend.js to be loaded first for $wpf, wpforo_phrase
7 *
8 * @since 3.0.0
9 */
10
11 $wpf(document).ready(function ($) {
12 var wpforo_wrap = $('#wpforo-wrap');
13 var chatContainer = wpforo_wrap.find('.wpf-ai-chat');
14
15 if (!chatContainer.length) {
16 return;
17 }
18
19 var chatNonce = chatContainer.data('nonce');
20 var currentConversationId = null;
21 var isLoading = false;
22
23 // =========================================================================
24 // HELPER FUNCTIONS
25 // =========================================================================
26
27 /**
28 * Format timestamp for display
29 */
30 function formatTime(dateStr) {
31 if (!dateStr) return '';
32 var date = new Date(dateStr);
33 return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
34 }
35
36 /**
37 * Format date for conversation list
38 */
39 function formatDate(dateStr) {
40 if (!dateStr) return '';
41 var date = new Date(dateStr);
42 var now = new Date();
43 var diff = now - date;
44 var dayMs = 24 * 60 * 60 * 1000;
45
46 if (diff < dayMs) {
47 return wpforo_phrase('Today');
48 } else if (diff < 2 * dayMs) {
49 return wpforo_phrase('Yesterday');
50 } else if (diff < 7 * dayMs) {
51 return date.toLocaleDateString([], { weekday: 'short' });
52 } else {
53 return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
54 }
55 }
56
57 /**
58 * Escape HTML for user-generated content
59 */
60 function escapeHtml(text) {
61 var div = document.createElement('div');
62 div.textContent = text;
63 return div.innerHTML;
64 }
65
66 /**
67 * Auto-resize textarea
68 */
69 function autoResizeTextarea(textarea) {
70 textarea.style.height = 'auto';
71 textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
72 }
73
74 // =========================================================================
75 // CONVERSATIONS MANAGEMENT
76 // =========================================================================
77
78 /**
79 * Load conversations list
80 */
81 function loadConversations() {
82 var listContainer = chatContainer.find('.wpf-ai-chat-conversations');
83 listContainer.html('<div class="wpf-ai-chat-loading"><i class="fas fa-spinner fa-spin"></i></div>');
84
85 $.ajax({
86 url: wpforo.ajax_url,
87 type: 'GET',
88 data: {
89 action: 'wpforo_ai_chat_get_conversations',
90 nonce: chatNonce
91 },
92 success: function (response) {
93 if (response.success && response.data.conversations) {
94 renderConversationsList(response.data.conversations);
95 } else {
96 listContainer.html('<div class="wpf-ai-chat-empty">' + wpforo_phrase('No conversations yet') + '</div>');
97 }
98 },
99 error: function () {
100 listContainer.html('<div class="wpf-ai-chat-error">' + wpforo_phrase('Error loading conversations') + '</div>');
101 }
102 });
103 }
104
105 /**
106 * Render conversations list
107 */
108 function renderConversationsList(conversations) {
109 var listContainer = chatContainer.find('.wpf-ai-chat-conversations');
110
111 if (!conversations || conversations.length === 0) {
112 listContainer.html('<div class="wpf-ai-chat-empty">' + wpforo_phrase('No conversations yet') + '</div>');
113 return;
114 }
115
116 var html = '';
117 conversations.forEach(function (conv) {
118 var title = conv.title || wpforo_phrase('New Conversation');
119 var date = formatDate(conv.updated_at || conv.created_at);
120 var activeClass = (conv.conversation_id == currentConversationId) ? ' wpf-ai-chat-conv-active' : '';
121
122 html += '<div class="wpf-ai-chat-conv' + activeClass + '" data-id="' + conv.conversation_id + '">';
123 html += '<div class="wpf-ai-chat-conv-content">';
124 html += '<div class="wpf-ai-chat-conv-title">' + escapeHtml(title) + '</div>';
125 html += '<div class="wpf-ai-chat-conv-meta">';
126 html += '<span class="wpf-ai-chat-conv-date">' + date + '</span>';
127 html += '<span class="wpf-ai-chat-conv-count">' + conv.message_count + ' ' + wpforo_phrase('messages') + '</span>';
128 html += '</div>';
129 html += '</div>';
130 html += '<button type="button" class="wpf-ai-chat-conv-delete" title="' + wpforo_phrase('Delete') + '">';
131 html += '<i class="fas fa-trash"></i>';
132 html += '</button>';
133 html += '</div>';
134 });
135
136 listContainer.html(html);
137 }
138
139 /**
140 * Check conversation limit before creating
141 */
142 function checkConversationLimit(callback) {
143 $.ajax({
144 url: wpforo.ajax_url,
145 type: 'GET',
146 data: {
147 action: 'wpforo_ai_chat_check_limit',
148 nonce: chatNonce
149 },
150 success: function (response) {
151 if (response.success) {
152 callback(response.data);
153 } else {
154 callback(null);
155 }
156 },
157 error: function () {
158 callback(null);
159 }
160 });
161 }
162
163 /**
164 * Show limit warning dialog
165 */
166 function showLimitWarningDialog(limitInfo) {
167 var oldest = limitInfo.oldest_conversation;
168 var title = oldest.title;
169 var msgCount = oldest.message_count;
170
171 var message = wpforo_phrase('You have reached the maximum number of conversations') + ' (' + limitInfo.max_allowed + ').\n\n';
172 message += wpforo_phrase('Creating a new conversation will automatically delete the oldest one') + ':\n';
173 message += '"' + title + '" (' + msgCount + ' ' + wpforo_phrase('messages') + ')\n\n';
174 message += wpforo_phrase('You can manually delete a different conversation from the list, or proceed to auto-delete the oldest one.');
175
176 return confirm(message);
177 }
178
179 /**
180 * Create new conversation (with limit check)
181 */
182 function createConversation() {
183 if (isLoading) return;
184
185 // First check if at limit
186 checkConversationLimit(function (limitInfo) {
187 if (limitInfo && limitInfo.at_limit) {
188 // Show warning dialog
189 if (!showLimitWarningDialog(limitInfo)) {
190 // User cancelled - don't create
191 return;
192 }
193 }
194
195 // Proceed with creation
196 doCreateConversation();
197 });
198 }
199
200 /**
201 * Actually create the conversation
202 */
203 function doCreateConversation() {
204 if (isLoading) return;
205 isLoading = true;
206
207 $.ajax({
208 url: wpforo.ajax_url,
209 type: 'POST',
210 data: {
211 action: 'wpforo_ai_chat_create_conversation',
212 nonce: chatNonce
213 },
214 success: function (response) {
215 isLoading = false;
216 if (response.success && response.data.conversation) {
217 currentConversationId = response.data.conversation.conversation_id;
218 loadConversations();
219 showChatArea(response.data.conversation, response.data.welcome_message);
220 } else {
221 alert(response.data?.message || wpforo_phrase('Error creating conversation'));
222 }
223 },
224 error: function () {
225 isLoading = false;
226 alert(wpforo_phrase('Network error. Please try again.'));
227 }
228 });
229 }
230
231 /**
232 * Delete conversation
233 */
234 function deleteConversation(conversationId) {
235 if (isLoading) return;
236
237 if (!confirm(wpforo_phrase('Are you sure you want to delete this conversation?'))) {
238 return;
239 }
240
241 isLoading = true;
242
243 $.ajax({
244 url: wpforo.ajax_url,
245 type: 'POST',
246 data: {
247 action: 'wpforo_ai_chat_delete_conversation',
248 nonce: chatNonce,
249 conversation_id: conversationId
250 },
251 success: function (response) {
252 isLoading = false;
253 if (response.success) {
254 if (conversationId == currentConversationId) {
255 currentConversationId = null;
256 showWelcomeArea();
257 }
258 loadConversations();
259 } else {
260 alert(response.data?.message || wpforo_phrase('Error deleting conversation'));
261 }
262 },
263 error: function () {
264 isLoading = false;
265 alert(wpforo_phrase('Network error. Please try again.'));
266 }
267 });
268 }
269
270 /**
271 * Load conversation messages
272 */
273 function loadConversation(conversationId) {
274 if (isLoading) return;
275 isLoading = true;
276
277 currentConversationId = conversationId;
278
279 // Update active state in list
280 chatContainer.find('.wpf-ai-chat-conv').removeClass('wpf-ai-chat-conv-active');
281 chatContainer.find('.wpf-ai-chat-conv[data-id="' + conversationId + '"]').addClass('wpf-ai-chat-conv-active');
282
283 // Show loading in messages area
284 var messagesContainer = chatContainer.find('.wpf-ai-chat-messages');
285 messagesContainer.html('<div class="wpf-ai-chat-loading"><i class="fas fa-spinner fa-spin"></i></div>');
286
287 $.ajax({
288 url: wpforo.ajax_url,
289 type: 'GET',
290 data: {
291 action: 'wpforo_ai_chat_get_messages',
292 nonce: chatNonce,
293 conversation_id: conversationId
294 },
295 success: function (response) {
296 isLoading = false;
297 if (response.success) {
298 showChatArea(response.data.conversation, null, response.data.messages);
299 } else {
300 messagesContainer.html('<div class="wpf-ai-chat-error">' + (response.data?.message || wpforo_phrase('Error loading messages')) + '</div>');
301 }
302 },
303 error: function () {
304 isLoading = false;
305 messagesContainer.html('<div class="wpf-ai-chat-error">' + wpforo_phrase('Network error. Please try again.') + '</div>');
306 }
307 });
308 }
309
310 // =========================================================================
311 // CHAT DISPLAY
312 // =========================================================================
313
314 /**
315 * Show welcome area (no conversation selected)
316 */
317 function showWelcomeArea() {
318 var messagesContainer = chatContainer.find('.wpf-ai-chat-messages');
319 var inputWrap = chatContainer.find('.wpf-ai-chat-input-wrap');
320
321 messagesContainer.html(
322 '<div class="wpf-ai-chat-welcome">' +
323 '<div class="wpf-ai-chat-welcome-icon">' +
324 '<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48">' +
325 '<path d="M9.5 2l1.5 4.5L15.5 8l-4.5 1.5L9.5 14l-1.5-4.5L3.5 8l4.5-1.5L9.5 2z"/>' +
326 '<path d="M18 12l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3z"/>' +
327 '</svg>' +
328 '</div>' +
329 '<p class="wpf-ai-chat-welcome-text">' + wpforo_phrase('Start a new conversation or select an existing one') + '</p>' +
330 '</div>'
331 );
332
333 inputWrap.hide();
334 chatContainer.find('input[name="conversation_id"]').val('');
335 }
336
337 /**
338 * Show chat area with messages
339 */
340 function showChatArea(conversation, welcomeMessage, messages) {
341 var messagesContainer = chatContainer.find('.wpf-ai-chat-messages');
342 var inputWrap = chatContainer.find('.wpf-ai-chat-input-wrap');
343
344 // Set conversation ID in form
345 chatContainer.find('input[name="conversation_id"]').val(conversation.conversation_id);
346
347 // Build messages HTML
348 var html = '';
349
350 // Add welcome message if provided
351 if (welcomeMessage) {
352 html += renderMessage({
353 role: 'assistant',
354 content: welcomeMessage,
355 created_at: new Date().toISOString()
356 });
357 }
358
359 // Add existing messages
360 if (messages && messages.length > 0) {
361 messages.forEach(function (msg) {
362 html += renderMessage(msg);
363 });
364 }
365
366 if (!html) {
367 html = '<div class="wpf-ai-chat-empty">' + wpforo_phrase('No messages yet. Start typing!') + '</div>';
368 }
369
370 messagesContainer.html(html);
371 inputWrap.show();
372
373 // Scroll to bottom
374 scrollToBottom();
375
376 // Focus input
377 chatContainer.find('.wpf-ai-chat-input').focus();
378 }
379
380 /**
381 * Render a single message
382 */
383 function renderMessage(msg) {
384 var isUser = msg.role === 'user';
385 var className = isUser ? 'wpf-ai-chat-msg-user' : 'wpf-ai-chat-msg-assistant';
386 var time = formatTime(msg.created_at);
387
388 // User messages: escape HTML and convert newlines
389 // Assistant messages: display as-is (already formatted by PHP)
390 var content = isUser
391 ? escapeHtml(msg.content).replace(/\n/g, '<br>')
392 : msg.content;
393
394 var html = '<div class="wpf-ai-chat-msg ' + className + '">';
395 html += '<div class="wpf-ai-chat-msg-content">';
396 html += '<div class="wpf-ai-chat-msg-text">' + content + '</div>';
397
398 // Add sources if available (only show if at least one source has a valid URL)
399 if (msg.sources && msg.sources.length > 0) {
400 var sourcesHtml = '';
401 msg.sources.forEach(function (source) {
402 // Support both 'url' (forum) and 'permalink' (WordPress)
403 var sourceUrl = source.url || source.permalink || '';
404 if (sourceUrl) {
405 var postId = source.post_id || '';
406 var title = source.title || wpforo_phrase('View topic');
407 var isWordPress = source.content_source === 'wordpress';
408 // Format: Title [icon 123] for WordPress, Title [123] for forum
409 var wpIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" style="fill:transparent; margin: 0 1px; width: 13px; height: 13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:2px"><path d="M4 4h16v16H4z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg>';
410 var idPrefix = isWordPress ? wpIcon : '';
411 sourcesHtml += '<a href="' + escapeHtml(sourceUrl) + '" class="wpf-ai-chat-msg-source" target="_blank">';
412 sourcesHtml += escapeHtml(title);
413 sourcesHtml += postId ? ' [' + idPrefix + escapeHtml(postId) + ']' : '';
414 sourcesHtml += '</a>';
415 }
416 });
417 // Only add sources section if we have valid source links
418 if (sourcesHtml) {
419 html += '<div class="wpf-ai-chat-msg-sources">';
420 html += '<span class="wpf-ai-chat-msg-sources-label">' + wpforo_phrase('Sources') + ':</span>';
421 html += sourcesHtml;
422 html += '</div>';
423 }
424 }
425
426 html += '<div class="wpf-ai-chat-msg-time">' + time + '</div>';
427 html += '</div>';
428 html += '</div>';
429
430 return html;
431 }
432
433 /**
434 * Append message to chat
435 */
436 function appendMessage(msg) {
437 var messagesContainer = chatContainer.find('.wpf-ai-chat-messages');
438
439 // Remove empty message placeholder
440 messagesContainer.find('.wpf-ai-chat-empty').remove();
441
442 // Append message
443 messagesContainer.append(renderMessage(msg));
444
445 // Scroll to bottom
446 scrollToBottom();
447 }
448
449 /**
450 * Show typing indicator
451 */
452 function showTypingIndicator() {
453 var messagesContainer = chatContainer.find('.wpf-ai-chat-messages');
454 messagesContainer.append(
455 '<div class="wpf-ai-chat-typing">' +
456 '<span></span><span></span><span></span>' +
457 '</div>'
458 );
459 scrollToBottom();
460 }
461
462 /**
463 * Hide typing indicator
464 */
465 function hideTypingIndicator() {
466 chatContainer.find('.wpf-ai-chat-typing').remove();
467 }
468
469 /**
470 * Scroll chat to bottom
471 */
472 function scrollToBottom() {
473 var messagesContainer = chatContainer.find('.wpf-ai-chat-messages');
474 messagesContainer.scrollTop(messagesContainer[0].scrollHeight);
475 }
476
477 // =========================================================================
478 // MESSAGE SENDING
479 // =========================================================================
480
481 /**
482 * Send a chat message
483 */
484 function sendMessage(message) {
485 if (isLoading || !currentConversationId || !message.trim()) return;
486 isLoading = true;
487
488 // Hide global wpforo loading indicator (chat has its own typing indicator)
489 wpforo_load_hide();
490
491 var textarea = chatContainer.find('.wpf-ai-chat-input');
492 var sendBtn = chatContainer.find('.wpf-ai-chat-send');
493
494 // Disable input
495 textarea.prop('disabled', true);
496 sendBtn.prop('disabled', true);
497
498 // Add user message to chat
499 appendMessage({
500 role: 'user',
501 content: message,
502 created_at: new Date().toISOString()
503 });
504
505 // Show typing indicator
506 showTypingIndicator();
507
508 // Clear input
509 textarea.val('').css('height', 'auto');
510
511 // Get local context if on a topic page
512 var localContext = getLocalContext();
513
514 $.ajax({
515 url: wpforo.ajax_url,
516 type: 'POST',
517 data: {
518 action: 'wpforo_ai_chat_send_message',
519 nonce: chatNonce,
520 conversation_id: currentConversationId,
521 message: message,
522 local_context: localContext
523 },
524 success: function (response) {
525 isLoading = false;
526 hideTypingIndicator();
527 wpforo_load_hide();
528 textarea.prop('disabled', false);
529 sendBtn.prop('disabled', false);
530
531 if (response.success) {
532 appendMessage({
533 role: 'assistant',
534 content: response.data.response,
535 sources: response.data.sources,
536 created_at: new Date().toISOString()
537 });
538
539 // Update conversation in list (message count)
540 loadConversations();
541 } else {
542 appendMessage({
543 role: 'assistant',
544 content: response.data?.message || wpforo_phrase('Sorry, I encountered an error. Please try again.'),
545 created_at: new Date().toISOString()
546 });
547 }
548
549 textarea.focus();
550 },
551 error: function () {
552 isLoading = false;
553 hideTypingIndicator();
554 wpforo_load_hide();
555 textarea.prop('disabled', false);
556 sendBtn.prop('disabled', false);
557
558 appendMessage({
559 role: 'assistant',
560 content: wpforo_phrase('Network error. Please check your connection and try again.'),
561 created_at: new Date().toISOString()
562 });
563
564 textarea.focus();
565 }
566 });
567 }
568
569 /**
570 * Get local context from current page
571 */
572 function getLocalContext() {
573 var context = {};
574
575 // Check if we're on a topic page
576 var topicTitle = wpforo_wrap.find('.wpforo-topic-title h1').text().trim();
577 if (topicTitle) {
578 context.topic_title = topicTitle;
579 }
580
581 // Get forum name from breadcrumb
582 var forumName = wpforo_wrap.find('.wpforo-breadcrumb a').last().text().trim();
583 if (forumName) {
584 context.forum_name = forumName;
585 }
586
587 // Get topic content (first post)
588 var topicContent = wpforo_wrap.find('.wpforo-post:first .wpf-post-content').text().trim();
589 if (topicContent) {
590 context.topic_content = topicContent.substring(0, 1000); // Limit to 1000 chars
591 }
592
593 return Object.keys(context).length > 0 ? context : null;
594 }
595
596 // =========================================================================
597 // EVENT HANDLERS
598 // =========================================================================
599
600 // Load conversations when chat tab is activated
601 wpforo_wrap.on('click', '.wpf-ai-tab[data-tab="ai-chat"]', function () {
602 if (!chatContainer.data('loaded')) {
603 loadConversations();
604 chatContainer.data('loaded', true);
605 }
606 });
607
608 // Create new conversation
609 chatContainer.on('click', '.wpf-ai-chat-new', function (e) {
610 e.preventDefault();
611 createConversation();
612 });
613
614 // Select conversation
615 chatContainer.on('click', '.wpf-ai-chat-conv', function (e) {
616 if ($(e.target).closest('.wpf-ai-chat-conv-delete').length) {
617 return; // Don't select if clicking delete button
618 }
619 var convId = $(this).data('id');
620 loadConversation(convId);
621 });
622
623 // Delete conversation
624 chatContainer.on('click', '.wpf-ai-chat-conv-delete', function (e) {
625 e.preventDefault();
626 e.stopPropagation();
627 var convId = $(this).closest('.wpf-ai-chat-conv').data('id');
628 deleteConversation(convId);
629 });
630
631 // Send message form submit
632 chatContainer.on('submit', '.wpf-ai-chat-form', function (e) {
633 e.preventDefault();
634 e.stopPropagation(); // Prevent global form handler from showing loading indicator
635 var message = chatContainer.find('.wpf-ai-chat-input').val().trim();
636 if (message) {
637 sendMessage(message);
638 }
639 });
640
641 // Enable/disable send button based on input
642 chatContainer.on('input', '.wpf-ai-chat-input', function () {
643 var textarea = $(this);
644 var sendBtn = chatContainer.find('.wpf-ai-chat-send');
645 sendBtn.prop('disabled', !textarea.val().trim());
646 autoResizeTextarea(this);
647 });
648
649 // Handle Enter key to send (Shift+Enter for new line)
650 chatContainer.on('keydown', '.wpf-ai-chat-input', function (e) {
651 if (e.key === 'Enter' && !e.shiftKey) {
652 e.preventDefault();
653 chatContainer.find('.wpf-ai-chat-form').submit();
654 }
655 });
656
657 // Initial load if chat tab is already active
658 if (chatContainer.closest('.wpf-ai-tab-content-active').length) {
659 loadConversations();
660 chatContainer.data('loaded', true);
661 }
662 });
663