PluginProbe
wpForo Forum / 3.0.2
wpForo Forum v3.0.2
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 1.4.2 All 137 releases
wpforo / classes / AIChatbot.php

AIChatbot.php in wpForo Forum 3.0.2, at classes/AIChatbot.php

924 lines 28.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 /**
6 * wpForo AI Chatbot
7 *
8 * Handles AI-powered chat functionality including:
9 * - Conversation management (create, list, delete)
10 * - Message handling with context management
11 * - Integration with RAG for forum knowledge
12 * - AJAX handlers for frontend chat widget
13 *
14 * @since 3.0.0
15 */
16 class AIChatbot {
17 use AIAjaxTrait;
18
19 /**
20 * Maximum conversations per user (default)
21 */
22 const DEFAULT_MAX_CONVERSATIONS = 10;
23
24 /**
25 * Context update threshold (messages)
26 */
27 const DEFAULT_CONTEXT_UPDATE_THRESHOLD = 5;
28
29 /**
30 * Constructor
31 */
32 public function __construct() {
33 // Only register AJAX handlers if chatbot is enabled
34 // Note: Only wp_ajax (logged-in users) - guests cannot use chatbot
35 if ( $this->is_enabled() ) {
36 add_action( 'wp_ajax_wpforo_ai_chat_send_message', [ $this, 'ajax_send_message' ] );
37 add_action( 'wp_ajax_wpforo_ai_chat_get_conversations', [ $this, 'ajax_get_conversations' ] );
38 add_action( 'wp_ajax_wpforo_ai_chat_get_messages', [ $this, 'ajax_get_messages' ] );
39 add_action( 'wp_ajax_wpforo_ai_chat_create_conversation', [ $this, 'ajax_create_conversation' ] );
40 add_action( 'wp_ajax_wpforo_ai_chat_delete_conversation', [ $this, 'ajax_delete_conversation' ] );
41 add_action( 'wp_ajax_wpforo_ai_chat_check_limit', [ $this, 'ajax_check_conversation_limit' ] );
42 }
43 }
44
45 /**
46 * Check if chatbot is enabled
47 *
48 * @return bool
49 */
50 public function is_enabled() {
51 return (bool) wpforo_setting( 'ai', 'chatbot' );
52 }
53
54 /**
55 * Check if current user can use chatbot
56 *
57 * @return bool
58 */
59 public function user_can_chat() {
60 // Check if chatbot is enabled
61 if ( ! $this->is_enabled() ) {
62 return false;
63 }
64
65 // Guests cannot use chatbot - login required
66 if ( ! WPF()->current_userid ) {
67 return false;
68 }
69
70 // Check allowed usergroups
71 $allowed_groups = wpforo_setting( 'ai', 'chatbot_allowed_groups' );
72 if ( ! empty( $allowed_groups ) && is_array( $allowed_groups ) ) {
73 $user_groupid = WPF()->current_user_groupid;
74 if ( ! in_array( (int) $user_groupid, $allowed_groups, false ) ) {
75 return false;
76 }
77 }
78
79 return true;
80 }
81
82 /**
83 * Get conversations for current user
84 *
85 * @param int $limit Maximum number of conversations
86 * @return array
87 */
88 public function get_conversations( $limit = 10 ) {
89 $userid = WPF()->current_userid;
90
91 $table = WPF()->tables->ai_chat_conversations;
92 $sql = WPF()->db->prepare(
93 "SELECT * FROM `{$table}` WHERE userid = %d ORDER BY updated_at DESC LIMIT %d",
94 $userid,
95 $limit
96 );
97
98 $conversations = WPF()->db->get_results( $sql, ARRAY_A );
99
100 // Parse key_facts JSON
101 foreach ( $conversations as &$conv ) {
102 $conv['key_facts'] = $conv['key_facts'] ? json_decode( $conv['key_facts'], true ) : [];
103 }
104
105 return $conversations ?: [];
106 }
107
108 /**
109 * Get a specific conversation
110 *
111 * @param int $conversation_id
112 * @return array|null
113 */
114 public function get_conversation( $conversation_id ) {
115 $userid = WPF()->current_userid;
116
117 $table = WPF()->tables->ai_chat_conversations;
118 $sql = WPF()->db->prepare(
119 "SELECT * FROM `{$table}` WHERE conversation_id = %d AND userid = %d",
120 $conversation_id,
121 $userid
122 );
123
124 $conversation = WPF()->db->get_row( $sql, ARRAY_A );
125
126 if ( $conversation ) {
127 $conversation['key_facts'] = $conversation['key_facts'] ? json_decode( $conversation['key_facts'], true ) : [];
128 }
129
130 return $conversation;
131 }
132
133 /**
134 * Create a new conversation
135 *
136 * @param string $title Optional title
137 * @return int|false Conversation ID or false on failure
138 */
139 public function create_conversation( $title = '' ) {
140 $userid = WPF()->current_userid;
141
142 // Check max conversations limit
143 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
144 $existing = $this->get_conversations( $max + 1 );
145
146 // Delete oldest conversations if over limit
147 if ( count( $existing ) >= $max ) {
148 $to_delete = array_slice( $existing, $max - 1 );
149 foreach ( $to_delete as $conv ) {
150 $this->delete_conversation( $conv['conversation_id'] );
151 }
152 }
153
154 $table = WPF()->tables->ai_chat_conversations;
155 $result = WPF()->db->insert(
156 $table,
157 [
158 'userid' => $userid,
159 'title' => $title ?: '',
160 'message_count' => 0,
161 'total_tokens' => 0,
162 'total_credits' => 0,
163 ],
164 [ '%d', '%s', '%d', '%d', '%d' ]
165 );
166
167 return $result ? WPF()->db->insert_id : false;
168 }
169
170 /**
171 * Update conversation context (summary and key facts)
172 *
173 * @param int $conversation_id
174 * @param string $summary
175 * @param array $key_facts
176 * @return bool
177 */
178 public function update_conversation_context( $conversation_id, $summary, $key_facts = [] ) {
179 $userid = WPF()->current_userid;
180
181 $table = WPF()->tables->ai_chat_conversations;
182 $result = WPF()->db->update(
183 $table,
184 [
185 'running_summary' => $summary,
186 'key_facts' => json_encode( $key_facts ),
187 ],
188 [
189 'conversation_id' => $conversation_id,
190 'userid' => $userid,
191 ],
192 [ '%s', '%s' ],
193 [ '%d', '%d' ]
194 );
195
196 return $result !== false;
197 }
198
199 /**
200 * Delete a conversation and its messages
201 *
202 * @param int $conversation_id
203 * @return bool
204 */
205 public function delete_conversation( $conversation_id ) {
206 $userid = WPF()->current_userid;
207
208 // Delete messages first
209 $messages_table = WPF()->tables->ai_chat_messages;
210 WPF()->db->delete(
211 $messages_table,
212 [ 'conversation_id' => $conversation_id ],
213 [ '%d' ]
214 );
215
216 // Delete conversation
217 $conv_table = WPF()->tables->ai_chat_conversations;
218 $result = WPF()->db->delete(
219 $conv_table,
220 [
221 'conversation_id' => $conversation_id,
222 'userid' => $userid,
223 ],
224 [ '%d', '%d' ]
225 );
226
227 return $result !== false;
228 }
229
230 /**
231 * Get messages for a conversation
232 *
233 * @param int $conversation_id
234 * @param int $limit
235 * @return array
236 */
237 public function get_messages( $conversation_id, $limit = 100 ) {
238
239 // Verify conversation ownership
240 $conversation = $this->get_conversation( $conversation_id );
241 if ( ! $conversation ) {
242 return [];
243 }
244
245 $table = WPF()->tables->ai_chat_messages;
246 $sql = WPF()->db->prepare(
247 "SELECT * FROM `{$table}` WHERE conversation_id = %d ORDER BY created_at ASC LIMIT %d",
248 $conversation_id,
249 $limit
250 );
251
252 $messages = WPF()->db->get_results( $sql, ARRAY_A );
253
254 // Parse sources_json and format assistant messages
255 foreach ( $messages as &$msg ) {
256 $msg['sources'] = $msg['sources_json'] ? json_decode( $msg['sources_json'], true ) : [];
257 unset( $msg['sources_json'] );
258
259 // Convert post_id to URL in sources (regenerate URLs from stored post_ids)
260 if ( ! empty( $msg['sources'] ) ) {
261 foreach ( $msg['sources'] as &$source ) {
262 if ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) {
263 $content_source = $source['content_source'] ?? 'wpforo';
264 if ( $content_source === 'wordpress' ) {
265 // WordPress content - use get_permalink() or stored permalink
266 if ( ! empty( $source['permalink'] ) ) {
267 $source['url'] = $source['permalink'];
268 } else {
269 $source['url'] = get_permalink( (int) $source['post_id'] );
270 }
271 } else {
272 // wpForo content - use WPF()->post->get_url()
273 $source['url'] = WPF()->post->get_url( (int) $source['post_id'] );
274 }
275 }
276 }
277 }
278
279 // Format assistant messages (convert markdown to HTML)
280 if ( $msg['role'] === 'assistant' && ! empty( $msg['content'] ) ) {
281 // Replace [NO_FORUM_CONTENT] placeholder with custom message
282 $original_content = $msg['content'];
283 $msg['content'] = $this->replace_no_content_placeholder( $msg['content'] );
284 $has_no_content = ( $msg['content'] !== $original_content );
285
286 // Skip markdown formatting for no-content messages (they may contain HTML)
287 if ( ! $has_no_content ) {
288 // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links
289 // (link markers must be processed AFTER format_ai_response to avoid HTML escaping)
290 $msg['content'] = $this->format_ai_response( $msg['content'] );
291 $msg['content'] = $this->replace_post_link_markers( $msg['content'] );
292 }
293 }
294 }
295
296 return $messages ?: [];
297 }
298
299 /**
300 * Get recent messages for context
301 *
302 * @param int $conversation_id
303 * @param int $limit
304 * @return array
305 */
306 public function get_recent_messages( $conversation_id, $limit = 3 ) {
307
308 $table = WPF()->tables->ai_chat_messages;
309 $sql = WPF()->db->prepare(
310 "SELECT role, content FROM `{$table}` WHERE conversation_id = %d ORDER BY created_at DESC LIMIT %d",
311 $conversation_id,
312 $limit
313 );
314
315 $messages = WPF()->db->get_results( $sql, ARRAY_A );
316
317 // Reverse to get chronological order
318 return array_reverse( $messages ?: [] );
319 }
320
321 /**
322 * Add a message to conversation
323 *
324 * @param int $conversation_id
325 * @param string $role 'user' or 'assistant'
326 * @param string $content
327 * @param array $metadata Optional metadata (tokens, credits, sources, etc.)
328 * @return int|false Message ID or false on failure
329 */
330 public function add_message( $conversation_id, $role, $content, $metadata = [] ) {
331 $table = WPF()->tables->ai_chat_messages;
332
333 $data = [
334 'conversation_id' => $conversation_id,
335 'role' => $role,
336 'content' => $content,
337 'tokens_used' => $metadata['tokens_used'] ?? 0,
338 'credits_spent' => $metadata['credits_spent'] ?? 0,
339 'quality_tier' => $metadata['quality_tier'] ?? 'fast',
340 'sources_count' => $metadata['sources_count'] ?? 0,
341 'sources_json' => ! empty( $metadata['sources'] ) ? json_encode( $metadata['sources'] ) : null,
342 'context_updated' => $metadata['context_updated'] ?? 0,
343 ];
344
345 $result = WPF()->db->insert(
346 $table,
347 $data,
348 [ '%d', '%s', '%s', '%d', '%d', '%s', '%d', '%s', '%d' ]
349 );
350
351 if ( $result ) {
352 // Update conversation stats
353 $this->update_conversation_stats(
354 $conversation_id,
355 $content,
356 $metadata['tokens_used'] ?? 0,
357 $metadata['credits_spent'] ?? 0
358 );
359 return WPF()->db->insert_id;
360 }
361
362 return false;
363 }
364
365 /**
366 * Update conversation statistics
367 *
368 * @param int $conversation_id
369 * @param string $message_content For title generation
370 * @param int $tokens
371 * @param int $credits
372 */
373 private function update_conversation_stats( $conversation_id, $message_content, $tokens, $credits ) {
374 $table = WPF()->tables->ai_chat_conversations;
375 $conversation = $this->get_conversation( $conversation_id );
376
377 if ( ! $conversation ) {
378 return;
379 }
380
381 $updates = [
382 'message_count' => $conversation['message_count'] + 1,
383 'total_tokens' => $conversation['total_tokens'] + $tokens,
384 'total_credits' => $conversation['total_credits'] + $credits,
385 'last_message_at' => current_time( 'mysql' ),
386 ];
387 $formats = [ '%d', '%d', '%d', '%s' ];
388
389 // Set title from first user message if empty (25 chars max)
390 if ( empty( $conversation['title'] ) && $conversation['message_count'] == 0 ) {
391 $title = trim( strip_tags( $message_content ) );
392 $updates['title'] = mb_strlen( $title ) > 25 ? mb_substr( $title, 0, 25 ) . '...' : $title;
393 $formats[] = '%s';
394 }
395
396 WPF()->db->update(
397 $table,
398 $updates,
399 [ 'conversation_id' => $conversation_id ],
400 $formats,
401 [ '%d' ]
402 );
403 }
404
405 /**
406 * Send message to AI and get response
407 *
408 * @param int $conversation_id
409 * @param string $message
410 * @param array $local_context Optional local page context
411 * @return array|WP_Error
412 */
413 public function send_message( $conversation_id, $message, $local_context = [] ) {
414 $conversation = $this->get_conversation( $conversation_id );
415 if ( ! $conversation ) {
416 return new \WP_Error( 'not_found', __( 'Conversation not found', 'wpforo' ) );
417 }
418
419 // Build conversation context for API
420 $recent_messages = $this->get_recent_messages( $conversation_id, 3 );
421
422 $conversation_context = [
423 'running_summary' => $conversation['running_summary'] ?? '',
424 'key_facts' => $conversation['key_facts'] ?? [],
425 'recent_messages' => $recent_messages,
426 'message_count' => (int) $conversation['message_count'],
427 ];
428
429 // Get settings
430 $quality = wpforo_setting( 'ai', 'chatbot_quality' ) ?: 'fast';
431 $use_rag = (bool) wpforo_setting( 'ai', 'chatbot_use_rag' );
432 $use_local = (bool) wpforo_setting( 'ai', 'chatbot_use_local_context' );
433 $context_threshold = (int) wpforo_setting( 'ai', 'chatbot_context_update_threshold' ) ?: self::DEFAULT_CONTEXT_UPDATE_THRESHOLD;
434 $no_content_message = wpforo_setting( 'ai', 'chatbot_no_content_message' ) ?: '';
435
436 // Get response language
437 $language = WPF()->ai_client->get_user_language( null, 'chatbot_language' );
438
439 // Build request data
440 $request_data = [
441 'message' => $message,
442 'conversation_context' => $conversation_context,
443 'settings' => [
444 'quality' => $quality,
445 'language' => $language,
446 'use_rag' => $use_rag,
447 'context_update_threshold' => $context_threshold,
448 'no_content_message' => $no_content_message,
449 ],
450 ];
451
452 // Add local context if enabled
453 if ( $use_local && ! empty( $local_context ) ) {
454 $request_data['local_context'] = $local_context;
455 }
456
457 // Check storage mode and perform local RAG search if needed
458 // When in local mode, WordPress performs the vector search and sends results to API
459 if ( $use_rag && WPF()->vector_storage->is_local_mode() ) {
460 $rag_results = $this->perform_local_rag_search( $message );
461 if ( ! is_wp_error( $rag_results ) && ! empty( $rag_results ) ) {
462 $request_data['rag_context'] = $rag_results;
463 }
464 }
465
466 // Call chat API via AIClient
467 $response = WPF()->ai_client->api_post( '/chat/message', $request_data, 60 );
468
469 if ( is_wp_error( $response ) ) {
470 return $response;
471 }
472
473 // Store user message
474 $this->add_message( $conversation_id, 'user', $message );
475
476 // Store assistant response
477 $credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3 ];
478 $credits = $credits_map[ $quality ] ?? 1;
479
480 $this->add_message( $conversation_id, 'assistant', $response['response'], [
481 'tokens_used' => $response['tokens_used'] ?? 0,
482 'credits_spent' => $credits,
483 'quality_tier' => $quality,
484 'sources_count' => count( $response['sources'] ?? [] ),
485 'sources' => $response['sources'] ?? [],
486 'context_updated' => ! empty( $response['context_update']['should_update'] ) ? 1 : 0,
487 ] );
488
489 // Update conversation context if API returned updates
490 if ( ! empty( $response['context_update']['should_update'] ) ) {
491 $this->update_conversation_context(
492 $conversation_id,
493 $response['context_update']['summary'] ?? '',
494 $response['context_update']['key_facts'] ?? []
495 );
496 }
497
498 // Replace [NO_FORUM_CONTENT] placeholder if present, then format
499 $response_text = $this->replace_no_content_placeholder( $response['response'] );
500 $has_no_content = ( $response_text !== $response['response'] );
501
502 // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links
503 // (link markers must be processed AFTER format_ai_response to avoid HTML escaping)
504 $formatted_response = $has_no_content ? $response_text : $this->format_ai_response( $response_text );
505 $formatted_response = $has_no_content ? $formatted_response : $this->replace_post_link_markers( $formatted_response );
506
507 // Convert post_id to url in sources (API sends post_id, PHP generates URL)
508 $sources = $has_no_content ? [] : ( $response['sources'] ?? [] );
509 foreach ( $sources as &$source ) {
510 if ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) {
511 $content_source = $source['content_source'] ?? 'wpforo';
512 if ( $content_source === 'wordpress' ) {
513 // WordPress content - use get_permalink() or stored permalink
514 if ( ! empty( $source['permalink'] ) ) {
515 $source['url'] = $source['permalink'];
516 } else {
517 $source['url'] = get_permalink( (int) $source['post_id'] );
518 }
519 } else {
520 // wpForo content - use WPF()->post->get_url()
521 $source['url'] = WPF()->post->get_url( (int) $source['post_id'] );
522 }
523 }
524 }
525
526 return [
527 'response' => $formatted_response,
528 'sources' => $sources,
529 ];
530 }
531
532 /**
533 * Replace [NO_FORUM_CONTENT] placeholder with custom message
534 *
535 * @param string $text Response text
536 * @return string Text with placeholder replaced (or original if no placeholder)
537 */
538 private function replace_no_content_placeholder( $text ) {
539 if ( strpos( $text, '[NO_FORUM_CONTENT]' ) === false ) {
540 return $text;
541 }
542
543 $custom_message = wpforo_setting( 'ai', 'chatbot_no_content_message' );
544 if ( ! empty( $custom_message ) ) {
545 // Replace placeholders with actual URLs
546 $add_topic_url = wpforo_home_url( '/add-topic/' );
547 $custom_message = str_replace( '{add_topic_url}', esc_url( $add_topic_url ), $custom_message );
548
549 // Use custom message with HTML preserved (sanitize with allowed tags)
550 $allowed_tags = '<a><br><p><img><strong><em><ul><ol><li>';
551 return strip_tags( $custom_message, $allowed_tags );
552 }
553
554 // Default message if no custom message set
555 return __( "I couldn't find information about that topic in the forum. Please try searching directly or create a new topic to start a discussion.", 'wpforo' );
556 }
557
558 /**
559 * Replace post link markers with superscript reference links
560 *
561 * Converts [[#POST_ID]] and [[#POST_ID:Title]] markers to superscript reference links.
562 * Also handles WordPress content markers like [[#wp_123]] and [[#wp_123:Title]].
563 * Format: <sup class="wpf-ai-chat-reference"><a href="url">[POST_ID]</a></sup>
564 *
565 * @param string $text Text containing link markers
566 * @return string Text with markers replaced by superscript links
567 */
568 private function replace_post_link_markers( $text ) {
569 if ( empty( $text ) ) {
570 return $text;
571 }
572
573 // First: Normalize common wrong formats from LLM
574 // Handle grouped citations like [[#1360],[#1506]] or [[#1360], [#1506]]
575 $text = preg_replace_callback(
576 '/\[\[#(\d+)\](?:,\s*\[#(\d+)\])+\]/',
577 function ( $matches ) {
578 // Extract all IDs from the grouped format
579 preg_match_all( '/#(\d+)/', $matches[0], $ids );
580 $result = [];
581 foreach ( $ids[1] as $id ) {
582 $result[] = '[[#' . $id . ']]';
583 }
584 return implode( ' ', $result );
585 },
586 $text
587 );
588
589 // Handle double brackets without hash: [[1360]] -> [[#1360]]
590 $text = preg_replace( '/\[\[(\d+)\]\]/', '[[#$1]]', $text );
591
592 // Handle single bracket format [1360] or [#1360] (without double brackets)
593 // Only match if it looks like a post ID (not part of markdown link)
594 $text = preg_replace( '/(?<!\[)\[#?(\d{2,})\](?!\])(?!\()/', '[[#$1]]', $text );
595
596 // Early exit if no citations to process
597 if ( strpos( $text, '[[#' ) === false ) {
598 return $text;
599 }
600
601 // Replace [[#wp_POST_ID:Title]] format - WordPress content with title
602 $text = preg_replace_callback(
603 '/\[\[#wp_(\d+):([^\]]+)\]\]/',
604 function ( $matches ) {
605 $postid = (int) $matches[1];
606 $title = trim( $matches[2] );
607 $url = get_permalink( $postid );
608 if ( empty( $url ) ) {
609 return ''; // Remove invalid references
610 }
611 // Use title as link text, with post ID in brackets for reference
612 return '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">' . esc_html( $title ) . '</a> <sup class="wpf-ai-chat-reference">[wp:' . $postid . ']</sup>';
613 },
614 $text
615 );
616
617 // Replace [[#wp_POST_ID]] format - WordPress content
618 $text = preg_replace_callback(
619 '/\[\[#wp_(\d+)\]\]/',
620 function ( $matches ) {
621 $postid = (int) $matches[1];
622 $url = get_permalink( $postid );
623 $title = get_the_title( $postid );
624 if ( empty( $url ) ) {
625 return ''; // Remove invalid references
626 }
627 // Use post title as link text if available
628 if ( ! empty( $title ) ) {
629 return '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">' . esc_html( $title ) . '</a> <sup class="wpf-ai-chat-reference">[wp:' . $postid . ']</sup>';
630 }
631 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[wp:' . $postid . ']</a></sup>';
632 },
633 $text
634 );
635
636 // Replace [[#POST_ID:Title]] format - wpForo content with title (ignore title, use post ID)
637 $text = preg_replace_callback(
638 '/\[\[#(\d+):([^\]]+)\]\]/',
639 function ( $matches ) {
640 $postid = (int) $matches[1];
641 $url = WPF()->post->get_url( $postid );
642 if ( empty( $url ) || $url === wpforo_home_url() ) {
643 return ''; // Remove invalid references
644 }
645 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[' . $postid . ']</a></sup>';
646 },
647 $text
648 );
649
650 // Replace [[#POST_ID]] format - wpForo content
651 $text = preg_replace_callback(
652 '/\[\[#(\d+)\]\]/',
653 function ( $matches ) {
654 $postid = (int) $matches[1];
655 $url = WPF()->post->get_url( $postid );
656 if ( empty( $url ) || $url === wpforo_home_url() ) {
657 return ''; // Remove invalid references
658 }
659 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[' . $postid . ']</a></sup>';
660 },
661 $text
662 );
663
664 return $text;
665 }
666
667 /**
668 * Format AI response text (convert markdown to HTML)
669 *
670 * Delegates to AIMarkdown::to_html() for consistent markdown conversion.
671 *
672 * @param string $text Raw AI response text
673 * @return string Formatted HTML
674 */
675 private function format_ai_response( $text ) {
676 return AIMarkdown::to_html( $text, AIMarkdown::MODE_FRONTEND );
677 }
678
679 /**
680 * Perform local RAG search using VectorStorageManager
681 *
682 * Searches local MySQL vector storage and formats results for API.
683 *
684 * @param string $query Search query
685 * @param int $limit Maximum results
686 * @return array|WP_Error Array of RAG results or WP_Error on failure
687 */
688 private function perform_local_rag_search( $query, $limit = 5 ) {
689 // Perform semantic search using VectorStorageManager
690 $search_results = WPF()->vector_storage->semantic_search( $query, $limit );
691
692 if ( is_wp_error( $search_results ) ) {
693 return $search_results;
694 }
695
696 if ( empty( $search_results['results'] ) ) {
697 return [];
698 }
699
700 // Transform results to API's expected rag_context format
701 $rag_context = [];
702 foreach ( $search_results['results'] as $result ) {
703 // Get forum name for context
704 $forum_name = '';
705 if ( ! empty( $result['forum_id'] ) ) {
706 $forum = WPF()->forum->get_forum( $result['forum_id'] );
707 $forum_name = $forum['title'] ?? '';
708 }
709
710 // Get author name
711 $author = '';
712 if ( ! empty( $result['user_id'] ) ) {
713 $user = WPF()->member->get_member( $result['user_id'] );
714 $author = $user['display_name'] ?? '';
715 }
716
717 $rag_context[] = [
718 'id' => (string) ( $result['post_id'] ?? $result['topic_id'] ),
719 'title' => $result['title'] ?? 'Untitled',
720 'content' => $result['content'] ?? '',
721 'url' => $result['post_url'] ?? $result['url'] ?? '',
722 'score' => (float) ( $result['score'] ?? 0 ),
723 'content_type' => ! empty( $result['post_id'] ) ? 'post' : 'topic',
724 'forum_name' => $forum_name,
725 'author' => $author,
726 ];
727 }
728
729 return $rag_context;
730 }
731
732 // =========================================================================
733 // AJAX HANDLERS
734 // =========================================================================
735
736 /**
737 * AJAX: Send a chat message
738 */
739 public function ajax_send_message() {
740 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
741
742 // Check rate limit (users have daily limits, moderators exempt)
743 $this->check_rate_limit( 'chatbot' );
744
745 if ( ! $this->user_can_chat() ) {
746 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
747 }
748
749 $conversation_id = $this->get_post_param( 'conversation_id', 0, 'int' );
750 $message = sanitize_textarea_field( wpfval( $_POST, 'message' ) );
751
752 if ( ! $conversation_id || ! $message ) {
753 $this->send_error( __( 'Missing required parameters', 'wpforo' ), 400 );
754 }
755
756 // Parse local context if provided
757 $local_context = [];
758 if ( ! empty( $_POST['local_context'] ) ) {
759 $local_context = [
760 'current_topic_title' => sanitize_text_field( wpfval( $_POST['local_context'], 'topic_title' ) ),
761 'current_forum_name' => sanitize_text_field( wpfval( $_POST['local_context'], 'forum_name' ) ),
762 'current_topic_content' => sanitize_textarea_field( wpfval( $_POST['local_context'], 'topic_content' ) ),
763 ];
764 }
765
766 $result = $this->send_message( $conversation_id, $message, $local_context );
767
768 if ( is_wp_error( $result ) ) {
769 $this->send_error( $result->get_error_message(), 500 );
770 }
771
772 $this->send_success( $result );
773 }
774
775 /**
776 * AJAX: Get user's conversations
777 */
778 public function ajax_get_conversations() {
779 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
780
781 if ( ! $this->user_can_chat() ) {
782 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
783 }
784
785 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
786 $conversations = $this->get_conversations( $max );
787
788 $this->send_success( [ 'conversations' => $conversations ] );
789 }
790
791 /**
792 * AJAX: Get messages for a conversation
793 */
794 public function ajax_get_messages() {
795 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
796
797 if ( ! $this->user_can_chat() ) {
798 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
799 }
800
801 $conversation_id = isset( $_GET['conversation_id'] ) ? intval( $_GET['conversation_id'] ) : 0;
802 if ( ! $conversation_id ) {
803 $this->send_error( __( 'Missing conversation ID', 'wpforo' ), 400 );
804 }
805
806 $messages = $this->get_messages( $conversation_id );
807 $conversation = $this->get_conversation( $conversation_id );
808
809 $this->send_success( [
810 'messages' => $messages,
811 'conversation' => $conversation,
812 ] );
813 }
814
815 /**
816 * AJAX: Create a new conversation
817 */
818 public function ajax_create_conversation() {
819 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
820
821 if ( ! $this->user_can_chat() ) {
822 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
823 }
824
825 $title = $this->get_post_param( 'title', '' );
826 $conversation_id = $this->create_conversation( $title );
827
828 if ( ! $conversation_id ) {
829 $this->send_error( __( 'Failed to create conversation', 'wpforo' ), 500 );
830 }
831
832 $conversation = $this->get_conversation( $conversation_id );
833
834 // Get welcome message and format it
835 $welcome = wpforo_setting( 'ai', 'chatbot_welcome_message' );
836 if ( empty( $welcome ) ) {
837 $welcome = __( "Hello! I'm your forum assistant. I can help you find information, answer questions about discussions, and navigate the forum. What would you like to know?", 'wpforo' );
838 }
839
840 // Replace {user_display_name} placeholder with actual user name
841 if ( WPF()->current_userid ) {
842 $display_name = wpforo_member( WPF()->current_userid, 'display_name' );
843 $welcome = str_replace( '{user_display_name}', esc_html( $display_name ), $welcome );
844 } else {
845 // For guests, remove the placeholder (and any space before it)
846 $welcome = str_replace( ' {user_display_name}', '', $welcome );
847 $welcome = str_replace( '{user_display_name}', '', $welcome );
848 }
849
850 // Convert line breaks to <br> and sanitize HTML
851 $welcome = nl2br( $welcome );
852 $allowed_tags = '<a><br><p><strong><em><ul><ol><li>';
853 $welcome = strip_tags( $welcome, $allowed_tags );
854
855 $this->send_success( [
856 'conversation' => $conversation,
857 'welcome_message' => $welcome,
858 ] );
859 }
860
861 /**
862 * AJAX: Delete a conversation
863 */
864 public function ajax_delete_conversation() {
865 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
866
867 if ( ! $this->user_can_chat() ) {
868 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
869 }
870
871 $conversation_id = $this->get_post_param( 'conversation_id', 0, 'int' );
872 if ( ! $conversation_id ) {
873 $this->send_error( __( 'Missing conversation ID', 'wpforo' ), 400 );
874 }
875
876 $result = $this->delete_conversation( $conversation_id );
877
878 if ( ! $result ) {
879 $this->send_error( __( 'Failed to delete conversation', 'wpforo' ), 500 );
880 }
881
882 $this->send_success( [ 'deleted' => true ] );
883 }
884
885 /**
886 * AJAX: Check if creating a new conversation would exceed the limit
887 *
888 * Returns information about which conversation would be auto-deleted
889 * if the user creates a new one.
890 */
891 public function ajax_check_conversation_limit() {
892 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
893
894 if ( ! $this->user_can_chat() ) {
895 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
896 }
897
898 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
899 $conversations = $this->get_conversations( $max + 1 );
900 $count = count( $conversations );
901
902 $response = [
903 'at_limit' => $count >= $max,
904 'max_allowed' => $max,
905 'current_count' => $count,
906 ];
907
908 // If at limit, include info about the oldest conversation that would be deleted
909 if ( $count >= $max && ! empty( $conversations ) ) {
910 // Get the oldest conversation (last in the array since sorted by updated_at DESC)
911 $oldest = end( $conversations );
912 $response['oldest_conversation'] = [
913 'conversation_id' => $oldest['conversation_id'],
914 'title' => $oldest['title'] ?: __( 'New Conversation', 'wpforo' ),
915 'message_count' => $oldest['message_count'],
916 'created_at' => $oldest['created_at'],
917 'updated_at' => $oldest['updated_at'],
918 ];
919 }
920
921 $this->send_success( $response );
922 }
923 }
924