PluginProbe
wpForo Forum / 3.1.2
wpForo Forum v3.1.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.1.2, at classes/AIChatbot.php

1,003 lines 34.0 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 // SECURITY: Verify ownership before deleting anything
209 // get_conversation() returns null if conversation doesn't belong to current user
210 $conversation = $this->get_conversation( $conversation_id );
211 if ( ! $conversation ) {
212 return false;
213 }
214
215 // Delete messages first (now safe - ownership verified above)
216 $messages_table = WPF()->tables->ai_chat_messages;
217 WPF()->db->delete(
218 $messages_table,
219 [ 'conversation_id' => $conversation_id ],
220 [ '%d' ]
221 );
222
223 // Delete conversation
224 $conv_table = WPF()->tables->ai_chat_conversations;
225 $result = WPF()->db->delete(
226 $conv_table,
227 [
228 'conversation_id' => $conversation_id,
229 'userid' => $userid,
230 ],
231 [ '%d', '%d' ]
232 );
233
234 return $result !== false;
235 }
236
237 /**
238 * Get messages for a conversation
239 *
240 * @param int $conversation_id
241 * @param int $limit
242 * @return array
243 */
244 public function get_messages( $conversation_id, $limit = 100 ) {
245
246 // Verify conversation ownership
247 $conversation = $this->get_conversation( $conversation_id );
248 if ( ! $conversation ) {
249 return [];
250 }
251
252 $table = WPF()->tables->ai_chat_messages;
253 $sql = WPF()->db->prepare(
254 "SELECT * FROM `{$table}` WHERE conversation_id = %d ORDER BY created_at ASC LIMIT %d",
255 $conversation_id,
256 $limit
257 );
258
259 $messages = WPF()->db->get_results( $sql, ARRAY_A );
260
261 // Parse sources_json and format assistant messages
262 foreach ( $messages as &$msg ) {
263 $msg['sources'] = $msg['sources_json'] ? json_decode( $msg['sources_json'], true ) : [];
264 unset( $msg['sources_json'] );
265
266 // Convert post_id to URL in sources (regenerate URLs from stored post_ids)
267 if ( ! empty( $msg['sources'] ) ) {
268 foreach ( $msg['sources'] as &$source ) {
269 if ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) {
270 $content_source = $source['content_source'] ?? 'wpforo';
271 if ( $content_source === 'wordpress' ) {
272 // WordPress content - use get_permalink() or stored permalink
273 if ( ! empty( $source['permalink'] ) ) {
274 $source['url'] = $source['permalink'];
275 } else {
276 $source['url'] = get_permalink( (int) $source['post_id'] );
277 }
278 } else {
279 // wpForo content - use WPF()->post->get_url()
280 $source['url'] = WPF()->post->get_url( (int) $source['post_id'] );
281 }
282 }
283 }
284 }
285
286 // Format assistant messages (convert markdown to HTML)
287 if ( $msg['role'] === 'assistant' && ! empty( $msg['content'] ) ) {
288 // Replace [NO_FORUM_CONTENT] placeholder with custom message
289 $original_content = $msg['content'];
290 $msg['content'] = $this->replace_no_content_placeholder( $msg['content'] );
291 $has_no_content = ( $msg['content'] !== $original_content );
292
293 // Skip markdown formatting for no-content messages (they may contain HTML)
294 if ( ! $has_no_content ) {
295 // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links
296 // (link markers must be processed AFTER format_ai_response to avoid HTML escaping)
297 $msg['content'] = $this->format_ai_response( $msg['content'] );
298 $msg['content'] = $this->replace_post_link_markers( $msg['content'] );
299 }
300 }
301 }
302
303 return $messages ?: [];
304 }
305
306 /**
307 * Get recent messages for context
308 *
309 * @param int $conversation_id
310 * @param int $limit
311 * @return array
312 */
313 public function get_recent_messages( $conversation_id, $limit = 3 ) {
314
315 $table = WPF()->tables->ai_chat_messages;
316 $sql = WPF()->db->prepare(
317 "SELECT role, content FROM `{$table}` WHERE conversation_id = %d ORDER BY created_at DESC LIMIT %d",
318 $conversation_id,
319 $limit
320 );
321
322 $messages = WPF()->db->get_results( $sql, ARRAY_A );
323
324 // Reverse to get chronological order
325 return array_reverse( $messages ?: [] );
326 }
327
328 /**
329 * Add a message to conversation
330 *
331 * @param int $conversation_id
332 * @param string $role 'user' or 'assistant'
333 * @param string $content
334 * @param array $metadata Optional metadata (tokens, credits, sources, etc.)
335 * @return int|false Message ID or false on failure
336 */
337 public function add_message( $conversation_id, $role, $content, $metadata = [] ) {
338 $table = WPF()->tables->ai_chat_messages;
339
340 $data = [
341 'conversation_id' => $conversation_id,
342 'role' => $role,
343 'content' => $content,
344 'tokens_used' => $metadata['tokens_used'] ?? 0,
345 'credits_spent' => $metadata['credits_spent'] ?? 0,
346 'quality_tier' => $metadata['quality_tier'] ?? 'fast',
347 'sources_count' => $metadata['sources_count'] ?? 0,
348 'sources_json' => ! empty( $metadata['sources'] ) ? json_encode( $metadata['sources'] ) : null,
349 'context_updated' => $metadata['context_updated'] ?? 0,
350 ];
351
352 $result = WPF()->db->insert(
353 $table,
354 $data,
355 [ '%d', '%s', '%s', '%d', '%d', '%s', '%d', '%s', '%d' ]
356 );
357
358 if ( $result ) {
359 // Update conversation stats
360 $this->update_conversation_stats(
361 $conversation_id,
362 $content,
363 $metadata['tokens_used'] ?? 0,
364 $metadata['credits_spent'] ?? 0
365 );
366 return WPF()->db->insert_id;
367 }
368
369 return false;
370 }
371
372 /**
373 * Update conversation statistics
374 *
375 * @param int $conversation_id
376 * @param string $message_content For title generation
377 * @param int $tokens
378 * @param int $credits
379 */
380 private function update_conversation_stats( $conversation_id, $message_content, $tokens, $credits ) {
381 $table = WPF()->tables->ai_chat_conversations;
382 $conversation = $this->get_conversation( $conversation_id );
383
384 if ( ! $conversation ) {
385 return;
386 }
387
388 $updates = [
389 'message_count' => $conversation['message_count'] + 1,
390 'total_tokens' => $conversation['total_tokens'] + $tokens,
391 'total_credits' => $conversation['total_credits'] + $credits,
392 'last_message_at' => current_time( 'mysql', true ), // UTC for timezone conversion
393 ];
394 $formats = [ '%d', '%d', '%d', '%s' ];
395
396 // Set title from first user message if empty (25 chars max)
397 if ( empty( $conversation['title'] ) && $conversation['message_count'] == 0 ) {
398 $title = trim( strip_tags( $message_content ) );
399 $updates['title'] = mb_strlen( $title ) > 25 ? mb_substr( $title, 0, 25 ) . '...' : $title;
400 $formats[] = '%s';
401 }
402
403 WPF()->db->update(
404 $table,
405 $updates,
406 [ 'conversation_id' => $conversation_id ],
407 $formats,
408 [ '%d' ]
409 );
410 }
411
412 /**
413 * Send message to AI and get response
414 *
415 * @param int $conversation_id
416 * @param string $message
417 * @param array $local_context Optional local page context
418 * @return array|WP_Error
419 */
420 public function send_message( $conversation_id, $message, $local_context = [] ) {
421 $conversation = $this->get_conversation( $conversation_id );
422 if ( ! $conversation ) {
423 return new \WP_Error( 'not_found', __( 'Conversation not found', 'wpforo' ) );
424 }
425
426 // Build conversation context for API
427 $recent_messages = $this->get_recent_messages( $conversation_id, 3 );
428
429 $conversation_context = [
430 'running_summary' => $conversation['running_summary'] ?? '',
431 'key_facts' => $conversation['key_facts'] ?? [],
432 'recent_messages' => $recent_messages,
433 'message_count' => (int) $conversation['message_count'],
434 ];
435
436 // Get settings
437 $quality = wpforo_setting( 'ai', 'chatbot_quality' ) ?: 'fast';
438 $use_rag = (bool) wpforo_setting( 'ai', 'chatbot_use_rag' );
439 $use_local = (bool) wpforo_setting( 'ai', 'chatbot_use_local_context' );
440 $context_threshold = (int) wpforo_setting( 'ai', 'chatbot_context_update_threshold' ) ?: self::DEFAULT_CONTEXT_UPDATE_THRESHOLD;
441 $no_content_message = wpforo_setting( 'ai', 'chatbot_no_content_message' ) ?: '';
442 $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
443
444 // Get response language
445 $language = WPF()->ai_client->get_user_language( null, 'chatbot_language' );
446
447 // Build request data
448 $request_data = [
449 'message' => $message,
450 'conversation_context' => $conversation_context,
451 'settings' => [
452 'quality' => $quality,
453 'language' => $language,
454 'use_rag' => $use_rag,
455 'context_update_threshold' => $context_threshold,
456 'no_content_message' => $no_content_message,
457 ],
458 ];
459
460 // Add min_score to settings if set (for cloud RAG filtering)
461 // Converted from percentage (30) to decimal (0.3) for API
462 if ( $min_score_setting > 0 ) {
463 $request_data['settings']['min_score'] = $min_score_setting / 100;
464 }
465
466 // Add custom knowledge parameters (Business+ cloud mode only)
467 if ( WPF()->ai_client->is_custom_knowledge_enabled() ) {
468 $request_data['settings']['include_custom_knowledge'] = true;
469 $request_data['settings']['knowledge_priority'] = [
470 'chat_priority' => WPF()->ai_client->get_knowledge_priorities( 'chat' ),
471 ];
472 }
473
474 // Add local context if enabled
475 if ( $use_local && ! empty( $local_context ) ) {
476 $request_data['local_context'] = $local_context;
477 }
478
479 // Check storage mode and perform local RAG search if needed
480 // When in local mode, WordPress performs the vector search and sends results to API
481 if ( $use_rag && WPF()->vector_storage->is_local_mode() ) {
482 $rag_results = $this->perform_local_rag_search( $message );
483 if ( ! is_wp_error( $rag_results ) && ! empty( $rag_results ) ) {
484 $request_data['rag_context'] = $rag_results;
485 }
486 }
487
488 // Call chat API via AIClient
489 $response = WPF()->ai_client->api_post( '/chat/message', $request_data, 60 );
490
491 if ( is_wp_error( $response ) ) {
492 return $response;
493 }
494
495 // Store user message
496 $this->add_message( $conversation_id, 'user', $message );
497
498 // Store assistant response
499 $credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3, 'premium' => 4 ];
500 $credits = $credits_map[ $quality ] ?? 2;
501
502 $this->add_message( $conversation_id, 'assistant', $response['response'], [
503 'tokens_used' => $response['tokens_used'] ?? 0,
504 'credits_spent' => $credits,
505 'quality_tier' => $quality,
506 'sources_count' => count( $response['sources'] ?? [] ),
507 'sources' => $response['sources'] ?? [],
508 'context_updated' => ! empty( $response['context_update']['should_update'] ) ? 1 : 0,
509 ] );
510
511 // Update conversation context if API returned updates
512 if ( ! empty( $response['context_update']['should_update'] ) ) {
513 $this->update_conversation_context(
514 $conversation_id,
515 $response['context_update']['summary'] ?? '',
516 $response['context_update']['key_facts'] ?? []
517 );
518 }
519
520 // Replace [NO_FORUM_CONTENT] placeholder if present, then format
521 $response_text = $this->replace_no_content_placeholder( $response['response'] );
522 $has_no_content = ( $response_text !== $response['response'] );
523
524 // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links
525 // (link markers must be processed AFTER format_ai_response to avoid HTML escaping)
526 $formatted_response = $has_no_content ? $response_text : $this->format_ai_response( $response_text );
527 $formatted_response = $has_no_content ? $formatted_response : $this->replace_post_link_markers( $formatted_response );
528
529 // Convert post_id to url in sources (API sends post_id, PHP generates URL)
530 $sources = $has_no_content ? [] : ( $response['sources'] ?? [] );
531 foreach ( $sources as &$source ) {
532 $content_source = $source['content_source'] ?? 'wpforo';
533
534 if ( $content_source === 'custom_knowledge' ) {
535 // Custom knowledge - no URL, just ensure proper labeling
536 $source['url'] = '';
537 $source['content_type_label'] = __( 'Knowledge Base', 'wpforo' );
538 } elseif ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) {
539 if ( $content_source === 'wordpress' ) {
540 // WordPress content - use get_permalink() or stored permalink
541 if ( ! empty( $source['permalink'] ) ) {
542 $source['url'] = $source['permalink'];
543 } else {
544 $source['url'] = get_permalink( (int) $source['post_id'] );
545 }
546 } else {
547 // wpForo content - use WPF()->post->get_url()
548 $source['url'] = WPF()->post->get_url( (int) $source['post_id'] );
549 }
550 }
551 }
552
553 return [
554 'response' => $formatted_response,
555 'sources' => $sources,
556 ];
557 }
558
559 /**
560 * Replace [NO_FORUM_CONTENT] placeholder with custom message
561 *
562 * @param string $text Response text
563 * @return string Text with placeholder replaced (or original if no placeholder)
564 */
565 private function replace_no_content_placeholder( $text ) {
566 if ( strpos( $text, '[NO_FORUM_CONTENT]' ) === false ) {
567 return $text;
568 }
569
570 $custom_message = wpforo_setting( 'ai', 'chatbot_no_content_message' );
571 if ( ! empty( $custom_message ) ) {
572 // Replace placeholders with actual URLs
573 $add_topic_url = wpforo_home_url( '/add-topic/' );
574 $custom_message = str_replace( '{add_topic_url}', esc_url( $add_topic_url ), $custom_message );
575
576 // Use custom message with HTML preserved (sanitize with allowed tags)
577 $allowed_tags = '<a><br><p><img><strong><em><ul><ol><li>';
578 return strip_tags( $custom_message, $allowed_tags );
579 }
580
581 // Default message if no custom message set
582 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' );
583 }
584
585 /**
586 * Replace post link markers with superscript reference links
587 *
588 * Converts [[#POST_ID]] and [[#POST_ID:Title]] markers to superscript reference links.
589 * Also handles WordPress content markers like [[#wp_123]] and [[#wp_123:Title]].
590 * Format: <sup class="wpf-ai-chat-reference"><a href="url">[POST_ID]</a></sup>
591 *
592 * @param string $text Text containing link markers
593 * @return string Text with markers replaced by superscript links
594 */
595 private function replace_post_link_markers( $text ) {
596 if ( empty( $text ) ) {
597 return $text;
598 }
599
600 // First: Normalize common wrong formats from LLM
601 // Handle grouped citations like [[#1360],[#1506]] or [[#1360], [#1506]]
602 $text = preg_replace_callback(
603 '/\[\[#(\d+)\](?:,\s*\[#(\d+)\])+\]/',
604 function ( $matches ) {
605 // Extract all IDs from the grouped format
606 preg_match_all( '/#(\d+)/', $matches[0], $ids );
607 $result = [];
608 foreach ( $ids[1] as $id ) {
609 $result[] = '[[#' . $id . ']]';
610 }
611 return implode( ' ', $result );
612 },
613 $text
614 );
615
616 // Handle double brackets without hash: [[1360]] -> [[#1360]]
617 $text = preg_replace( '/\[\[(\d+)\]\]/', '[[#$1]]', $text );
618
619 // Handle single bracket format [1360] or [#1360] (without double brackets)
620 // Only match if it looks like a post ID (not part of markdown link)
621 $text = preg_replace( '/(?<!\[)\[#?(\d{2,})\](?!\])(?!\()/', '[[#$1]]', $text );
622
623 // Early exit if no citations to process
624 // Check for [[# (standard), [[wp_ (WordPress), [[kb_ (knowledge base)
625 if ( strpos( $text, '[[#' ) === false && strpos( $text, '[[wp_' ) === false && strpos( $text, '[[kb_' ) === false ) {
626 return $text;
627 }
628
629 // Replace [[#kb_FILE_ID_CHUNK]] format - Knowledge Base content (no URL, show badge)
630 // Matches patterns like [[#kb_59a02953-e6ca-40b6-9d0f-5f482f340b10_383]]
631 $text = preg_replace_callback(
632 '/\[\[#?kb_[a-f0-9\-]+_\d+(?::[^\]]+)?\]\]/',
633 function ( $matches ) {
634 // Show a simple knowledge base indicator (no link since KB has no URL)
635 return '<sup class="wpf-ai-chat-reference wpf-ai-chat-kb-ref" title="' . esc_attr__( 'Source: Knowledge Base', 'wpforo' ) . '"><span>[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>KB]</span></sup>';
636 },
637 $text
638 );
639
640 // Replace [[#wp_POST_ID:Title]] format - WordPress content with title (title ignored)
641 $text = preg_replace_callback(
642 '/\[\[#wp_(\d+):([^\]]+)\]\]/',
643 function ( $matches ) {
644 $postid = (int) $matches[1];
645 $url = get_permalink( $postid );
646 if ( empty( $url ) ) {
647 return ''; // Remove invalid references
648 }
649 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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>' . $postid . ']</a></sup>';
650 },
651 $text
652 );
653
654 // Replace [[#wp_POST_ID]] format - WordPress content
655 $text = preg_replace_callback(
656 '/\[\[#wp_(\d+)\]\]/',
657 function ( $matches ) {
658 $postid = (int) $matches[1];
659 $url = get_permalink( $postid );
660 if ( empty( $url ) ) {
661 return ''; // Remove invalid references
662 }
663 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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>' . $postid . ']</a></sup>';
664 },
665 $text
666 );
667
668 // Fallback: Replace [[wp_POST_ID:Title]] format - WordPress content missing # (AI often forgets hash)
669 $text = preg_replace_callback(
670 '/\[\[wp_(\d+):([^\]]+)\]\]/',
671 function ( $matches ) {
672 $postid = (int) $matches[1];
673 $url = get_permalink( $postid );
674 if ( empty( $url ) ) {
675 return ''; // Remove invalid references
676 }
677 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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>' . $postid . ']</a></sup>';
678 },
679 $text
680 );
681
682 // Fallback: Replace [[wp_POST_ID]] format - WordPress content missing # (AI often forgets hash)
683 $text = preg_replace_callback(
684 '/\[\[wp_(\d+)\]\]/',
685 function ( $matches ) {
686 $postid = (int) $matches[1];
687 $url = get_permalink( $postid );
688 if ( empty( $url ) ) {
689 return ''; // Remove invalid references
690 }
691 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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>' . $postid . ']</a></sup>';
692 },
693 $text
694 );
695
696 // Replace [[#POST_ID:Title]] format - wpForo content with title (ignore title, use post ID)
697 $text = preg_replace_callback(
698 '/\[\[#(\d+):([^\]]+)\]\]/',
699 function ( $matches ) {
700 $postid = (int) $matches[1];
701 $url = WPF()->post->get_url( $postid );
702 if ( empty( $url ) || $url === wpforo_home_url() ) {
703 return ''; // Remove invalid references
704 }
705 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[' . $postid . ']</a></sup>';
706 },
707 $text
708 );
709
710 // Replace [[#POST_ID]] format - wpForo content
711 $text = preg_replace_callback(
712 '/\[\[#(\d+)\]\]/',
713 function ( $matches ) {
714 $postid = (int) $matches[1];
715 $url = WPF()->post->get_url( $postid );
716 if ( empty( $url ) || $url === wpforo_home_url() ) {
717 return ''; // Remove invalid references
718 }
719 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[' . $postid . ']</a></sup>';
720 },
721 $text
722 );
723
724 return $text;
725 }
726
727 /**
728 * Format AI response text (convert markdown to HTML)
729 *
730 * Delegates to AIMarkdown::to_html() for consistent markdown conversion.
731 *
732 * @param string $text Raw AI response text
733 * @return string Formatted HTML
734 */
735 private function format_ai_response( $text ) {
736 return AIMarkdown::to_html( $text, AIMarkdown::MODE_FRONTEND );
737 }
738
739 /**
740 * Perform local RAG search using VectorStorageManager
741 *
742 * Searches local MySQL vector storage and formats results for API.
743 *
744 * @param string $query Search query
745 * @param int $limit Maximum results
746 * @return array|WP_Error Array of RAG results or WP_Error on failure
747 */
748 private function perform_local_rag_search( $query, $limit = 5 ) {
749 // Get chatbot-specific min_score setting and apply local mode scaling
750 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
751 // so apply 1/3 of the configured threshold for local mode with 15% absolute minimum.
752 $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
753 $local_threshold = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
754 $absolute_min = 0.15; // 15% - below this, results are definitely garbage
755 $filters = [ 'min_score' => max( $local_threshold, $absolute_min ) ];
756
757 // Perform semantic search using VectorStorageManager with chatbot's score threshold
758 $search_results = WPF()->vector_storage->semantic_search( $query, $limit, $filters );
759
760 if ( is_wp_error( $search_results ) ) {
761 return $search_results;
762 }
763
764 if ( empty( $search_results['results'] ) ) {
765 return [];
766 }
767
768 // Transform results to API's expected rag_context format
769 $rag_context = [];
770 foreach ( $search_results['results'] as $result ) {
771 $content_source = $result['content_source'] ?? 'forum';
772 $is_wordpress = ( $content_source === 'wordpress' );
773
774 // Get forum name for context (only for forum content)
775 $forum_name = '';
776 if ( ! $is_wordpress && ! empty( $result['forum_id'] ) ) {
777 $forum = WPF()->forum->get_forum( $result['forum_id'] );
778 $forum_name = $forum['title'] ?? '';
779 }
780
781 // Get author name
782 $author = '';
783 if ( ! empty( $result['user_id'] ) ) {
784 $user = WPF()->member->get_member( $result['user_id'] );
785 $author = $user['display_name'] ?? '';
786 }
787
788 // Build ID with wp_ prefix for WordPress content so LLM cites correctly
789 // Use wpfval() for safe array access (avoids PHP 8 undefined key warnings)
790 $post_id = wpfval( $result, 'post_id' ) ?: wpfval( $result, 'topic_id' ) ?: '';
791 $id = $is_wordpress ? 'wp_' . $post_id : (string) $post_id;
792
793 $rag_context[] = [
794 'id' => $id,
795 'post_id' => (string) $post_id,
796 'title' => $result['title'] ?? 'Untitled',
797 'content' => $result['content'] ?? '',
798 'url' => $result['post_url'] ?? $result['url'] ?? '',
799 'score' => (float) ( $result['score'] ?? 0 ),
800 'content_type' => ! empty( $result['post_id'] ) ? 'post' : 'topic',
801 'content_source' => $content_source,
802 'permalink' => $is_wordpress ? ( $result['url'] ?? $result['post_url'] ?? '' ) : '',
803 'forum_name' => $forum_name,
804 'author' => $author,
805 ];
806 }
807
808 return $rag_context;
809 }
810
811 // =========================================================================
812 // AJAX HANDLERS
813 // =========================================================================
814
815 /**
816 * AJAX: Send a chat message
817 */
818 public function ajax_send_message() {
819 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
820
821 // Check rate limit (users have daily limits, moderators exempt)
822 $this->check_rate_limit( 'chatbot' );
823
824 if ( ! $this->user_can_chat() ) {
825 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
826 }
827
828 $conversation_id = $this->get_post_param( 'conversation_id', 0, 'int' );
829 $message = sanitize_textarea_field( wpfval( $_POST, 'message' ) );
830
831 if ( ! $conversation_id || ! $message ) {
832 $this->send_error( __( 'Missing required parameters', 'wpforo' ), 400 );
833 }
834
835 // Parse local context if provided
836 $local_context = [];
837 if ( ! empty( $_POST['local_context'] ) ) {
838 $local_context = [
839 'current_topic_title' => sanitize_text_field( wpfval( $_POST['local_context'], 'topic_title' ) ),
840 'current_forum_name' => sanitize_text_field( wpfval( $_POST['local_context'], 'forum_name' ) ),
841 'current_topic_content' => sanitize_textarea_field( wpfval( $_POST['local_context'], 'topic_content' ) ),
842 ];
843 }
844
845 $result = $this->send_message( $conversation_id, $message, $local_context );
846
847 if ( is_wp_error( $result ) ) {
848 $this->send_error( $result->get_error_message(), 500 );
849 }
850
851 $this->send_success( $result );
852 }
853
854 /**
855 * AJAX: Get user's conversations
856 */
857 public function ajax_get_conversations() {
858 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
859
860 if ( ! $this->user_can_chat() ) {
861 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
862 }
863
864 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
865 $conversations = $this->get_conversations( $max );
866
867 $this->send_success( [ 'conversations' => $conversations ] );
868 }
869
870 /**
871 * AJAX: Get messages for a conversation
872 */
873 public function ajax_get_messages() {
874 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
875
876 if ( ! $this->user_can_chat() ) {
877 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
878 }
879
880 $conversation_id = isset( $_GET['conversation_id'] ) ? intval( $_GET['conversation_id'] ) : 0;
881 if ( ! $conversation_id ) {
882 $this->send_error( __( 'Missing conversation ID', 'wpforo' ), 400 );
883 }
884
885 $messages = $this->get_messages( $conversation_id );
886 $conversation = $this->get_conversation( $conversation_id );
887
888 $this->send_success( [
889 'messages' => $messages,
890 'conversation' => $conversation,
891 ] );
892 }
893
894 /**
895 * AJAX: Create a new conversation
896 */
897 public function ajax_create_conversation() {
898 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
899
900 if ( ! $this->user_can_chat() ) {
901 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
902 }
903
904 $title = $this->get_post_param( 'title', '' );
905 $conversation_id = $this->create_conversation( $title );
906
907 if ( ! $conversation_id ) {
908 $this->send_error( __( 'Failed to create conversation', 'wpforo' ), 500 );
909 }
910
911 $conversation = $this->get_conversation( $conversation_id );
912
913 // Get welcome message and format it
914 $welcome = wpforo_setting( 'ai', 'chatbot_welcome_message' );
915 if ( empty( $welcome ) ) {
916 $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' );
917 }
918
919 // Replace {user_display_name} placeholder with actual user name
920 if ( WPF()->current_userid ) {
921 $display_name = wpforo_member( WPF()->current_userid, 'display_name' );
922 $welcome = str_replace( '{user_display_name}', esc_html( $display_name ), $welcome );
923 } else {
924 // For guests, remove the placeholder (and any space before it)
925 $welcome = str_replace( ' {user_display_name}', '', $welcome );
926 $welcome = str_replace( '{user_display_name}', '', $welcome );
927 }
928
929 // Convert line breaks to <br> and sanitize HTML
930 $welcome = nl2br( $welcome );
931 $allowed_tags = '<a><br><p><strong><em><ul><ol><li>';
932 $welcome = strip_tags( $welcome, $allowed_tags );
933
934 $this->send_success( [
935 'conversation' => $conversation,
936 'welcome_message' => $welcome,
937 ] );
938 }
939
940 /**
941 * AJAX: Delete a conversation
942 */
943 public function ajax_delete_conversation() {
944 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
945
946 if ( ! $this->user_can_chat() ) {
947 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
948 }
949
950 $conversation_id = $this->get_post_param( 'conversation_id', 0, 'int' );
951 if ( ! $conversation_id ) {
952 $this->send_error( __( 'Missing conversation ID', 'wpforo' ), 400 );
953 }
954
955 $result = $this->delete_conversation( $conversation_id );
956
957 if ( ! $result ) {
958 $this->send_error( __( 'Failed to delete conversation', 'wpforo' ), 500 );
959 }
960
961 $this->send_success( [ 'deleted' => true ] );
962 }
963
964 /**
965 * AJAX: Check if creating a new conversation would exceed the limit
966 *
967 * Returns information about which conversation would be auto-deleted
968 * if the user creates a new one.
969 */
970 public function ajax_check_conversation_limit() {
971 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
972
973 if ( ! $this->user_can_chat() ) {
974 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
975 }
976
977 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
978 $conversations = $this->get_conversations( $max + 1 );
979 $count = count( $conversations );
980
981 $response = [
982 'at_limit' => $count >= $max,
983 'max_allowed' => $max,
984 'current_count' => $count,
985 ];
986
987 // If at limit, include info about the oldest conversation that would be deleted
988 if ( $count >= $max && ! empty( $conversations ) ) {
989 // Get the oldest conversation (last in the array since sorted by updated_at DESC)
990 $oldest = end( $conversations );
991 $response['oldest_conversation'] = [
992 'conversation_id' => $oldest['conversation_id'],
993 'title' => $oldest['title'] ?: __( 'New Conversation', 'wpforo' ),
994 'message_count' => $oldest['message_count'],
995 'created_at' => $oldest['created_at'],
996 'updated_at' => $oldest['updated_at'],
997 ];
998 }
999
1000 $this->send_success( $response );
1001 }
1002 }
1003