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

996 lines 33.7 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', true ), // UTC for timezone conversion
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 $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
436
437 // Get response language
438 $language = WPF()->ai_client->get_user_language( null, 'chatbot_language' );
439
440 // Build request data
441 $request_data = [
442 'message' => $message,
443 'conversation_context' => $conversation_context,
444 'settings' => [
445 'quality' => $quality,
446 'language' => $language,
447 'use_rag' => $use_rag,
448 'context_update_threshold' => $context_threshold,
449 'no_content_message' => $no_content_message,
450 ],
451 ];
452
453 // Add min_score to settings if set (for cloud RAG filtering)
454 // Converted from percentage (30) to decimal (0.3) for API
455 if ( $min_score_setting > 0 ) {
456 $request_data['settings']['min_score'] = $min_score_setting / 100;
457 }
458
459 // Add custom knowledge parameters (Business+ cloud mode only)
460 if ( WPF()->ai_client->is_custom_knowledge_enabled() ) {
461 $request_data['settings']['include_custom_knowledge'] = true;
462 $request_data['settings']['knowledge_priority'] = [
463 'chat_priority' => WPF()->ai_client->get_knowledge_priorities( 'chat' ),
464 ];
465 }
466
467 // Add local context if enabled
468 if ( $use_local && ! empty( $local_context ) ) {
469 $request_data['local_context'] = $local_context;
470 }
471
472 // Check storage mode and perform local RAG search if needed
473 // When in local mode, WordPress performs the vector search and sends results to API
474 if ( $use_rag && WPF()->vector_storage->is_local_mode() ) {
475 $rag_results = $this->perform_local_rag_search( $message );
476 if ( ! is_wp_error( $rag_results ) && ! empty( $rag_results ) ) {
477 $request_data['rag_context'] = $rag_results;
478 }
479 }
480
481 // Call chat API via AIClient
482 $response = WPF()->ai_client->api_post( '/chat/message', $request_data, 60 );
483
484 if ( is_wp_error( $response ) ) {
485 return $response;
486 }
487
488 // Store user message
489 $this->add_message( $conversation_id, 'user', $message );
490
491 // Store assistant response
492 $credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3, 'premium' => 4 ];
493 $credits = $credits_map[ $quality ] ?? 2;
494
495 $this->add_message( $conversation_id, 'assistant', $response['response'], [
496 'tokens_used' => $response['tokens_used'] ?? 0,
497 'credits_spent' => $credits,
498 'quality_tier' => $quality,
499 'sources_count' => count( $response['sources'] ?? [] ),
500 'sources' => $response['sources'] ?? [],
501 'context_updated' => ! empty( $response['context_update']['should_update'] ) ? 1 : 0,
502 ] );
503
504 // Update conversation context if API returned updates
505 if ( ! empty( $response['context_update']['should_update'] ) ) {
506 $this->update_conversation_context(
507 $conversation_id,
508 $response['context_update']['summary'] ?? '',
509 $response['context_update']['key_facts'] ?? []
510 );
511 }
512
513 // Replace [NO_FORUM_CONTENT] placeholder if present, then format
514 $response_text = $this->replace_no_content_placeholder( $response['response'] );
515 $has_no_content = ( $response_text !== $response['response'] );
516
517 // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links
518 // (link markers must be processed AFTER format_ai_response to avoid HTML escaping)
519 $formatted_response = $has_no_content ? $response_text : $this->format_ai_response( $response_text );
520 $formatted_response = $has_no_content ? $formatted_response : $this->replace_post_link_markers( $formatted_response );
521
522 // Convert post_id to url in sources (API sends post_id, PHP generates URL)
523 $sources = $has_no_content ? [] : ( $response['sources'] ?? [] );
524 foreach ( $sources as &$source ) {
525 $content_source = $source['content_source'] ?? 'wpforo';
526
527 if ( $content_source === 'custom_knowledge' ) {
528 // Custom knowledge - no URL, just ensure proper labeling
529 $source['url'] = '';
530 $source['content_type_label'] = __( 'Knowledge Base', 'wpforo' );
531 } elseif ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) {
532 if ( $content_source === 'wordpress' ) {
533 // WordPress content - use get_permalink() or stored permalink
534 if ( ! empty( $source['permalink'] ) ) {
535 $source['url'] = $source['permalink'];
536 } else {
537 $source['url'] = get_permalink( (int) $source['post_id'] );
538 }
539 } else {
540 // wpForo content - use WPF()->post->get_url()
541 $source['url'] = WPF()->post->get_url( (int) $source['post_id'] );
542 }
543 }
544 }
545
546 return [
547 'response' => $formatted_response,
548 'sources' => $sources,
549 ];
550 }
551
552 /**
553 * Replace [NO_FORUM_CONTENT] placeholder with custom message
554 *
555 * @param string $text Response text
556 * @return string Text with placeholder replaced (or original if no placeholder)
557 */
558 private function replace_no_content_placeholder( $text ) {
559 if ( strpos( $text, '[NO_FORUM_CONTENT]' ) === false ) {
560 return $text;
561 }
562
563 $custom_message = wpforo_setting( 'ai', 'chatbot_no_content_message' );
564 if ( ! empty( $custom_message ) ) {
565 // Replace placeholders with actual URLs
566 $add_topic_url = wpforo_home_url( '/add-topic/' );
567 $custom_message = str_replace( '{add_topic_url}', esc_url( $add_topic_url ), $custom_message );
568
569 // Use custom message with HTML preserved (sanitize with allowed tags)
570 $allowed_tags = '<a><br><p><img><strong><em><ul><ol><li>';
571 return strip_tags( $custom_message, $allowed_tags );
572 }
573
574 // Default message if no custom message set
575 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' );
576 }
577
578 /**
579 * Replace post link markers with superscript reference links
580 *
581 * Converts [[#POST_ID]] and [[#POST_ID:Title]] markers to superscript reference links.
582 * Also handles WordPress content markers like [[#wp_123]] and [[#wp_123:Title]].
583 * Format: <sup class="wpf-ai-chat-reference"><a href="url">[POST_ID]</a></sup>
584 *
585 * @param string $text Text containing link markers
586 * @return string Text with markers replaced by superscript links
587 */
588 private function replace_post_link_markers( $text ) {
589 if ( empty( $text ) ) {
590 return $text;
591 }
592
593 // First: Normalize common wrong formats from LLM
594 // Handle grouped citations like [[#1360],[#1506]] or [[#1360], [#1506]]
595 $text = preg_replace_callback(
596 '/\[\[#(\d+)\](?:,\s*\[#(\d+)\])+\]/',
597 function ( $matches ) {
598 // Extract all IDs from the grouped format
599 preg_match_all( '/#(\d+)/', $matches[0], $ids );
600 $result = [];
601 foreach ( $ids[1] as $id ) {
602 $result[] = '[[#' . $id . ']]';
603 }
604 return implode( ' ', $result );
605 },
606 $text
607 );
608
609 // Handle double brackets without hash: [[1360]] -> [[#1360]]
610 $text = preg_replace( '/\[\[(\d+)\]\]/', '[[#$1]]', $text );
611
612 // Handle single bracket format [1360] or [#1360] (without double brackets)
613 // Only match if it looks like a post ID (not part of markdown link)
614 $text = preg_replace( '/(?<!\[)\[#?(\d{2,})\](?!\])(?!\()/', '[[#$1]]', $text );
615
616 // Early exit if no citations to process
617 // Check for [[# (standard), [[wp_ (WordPress), [[kb_ (knowledge base)
618 if ( strpos( $text, '[[#' ) === false && strpos( $text, '[[wp_' ) === false && strpos( $text, '[[kb_' ) === false ) {
619 return $text;
620 }
621
622 // Replace [[#kb_FILE_ID_CHUNK]] format - Knowledge Base content (no URL, show badge)
623 // Matches patterns like [[#kb_59a02953-e6ca-40b6-9d0f-5f482f340b10_383]]
624 $text = preg_replace_callback(
625 '/\[\[#?kb_[a-f0-9\-]+_\d+(?::[^\]]+)?\]\]/',
626 function ( $matches ) {
627 // Show a simple knowledge base indicator (no link since KB has no URL)
628 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>';
629 },
630 $text
631 );
632
633 // Replace [[#wp_POST_ID:Title]] format - WordPress content with title (title ignored)
634 $text = preg_replace_callback(
635 '/\[\[#wp_(\d+):([^\]]+)\]\]/',
636 function ( $matches ) {
637 $postid = (int) $matches[1];
638 $url = get_permalink( $postid );
639 if ( empty( $url ) ) {
640 return ''; // Remove invalid references
641 }
642 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>';
643 },
644 $text
645 );
646
647 // Replace [[#wp_POST_ID]] format - WordPress content
648 $text = preg_replace_callback(
649 '/\[\[#wp_(\d+)\]\]/',
650 function ( $matches ) {
651 $postid = (int) $matches[1];
652 $url = get_permalink( $postid );
653 if ( empty( $url ) ) {
654 return ''; // Remove invalid references
655 }
656 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>';
657 },
658 $text
659 );
660
661 // Fallback: Replace [[wp_POST_ID:Title]] format - WordPress content missing # (AI often forgets hash)
662 $text = preg_replace_callback(
663 '/\[\[wp_(\d+):([^\]]+)\]\]/',
664 function ( $matches ) {
665 $postid = (int) $matches[1];
666 $url = get_permalink( $postid );
667 if ( empty( $url ) ) {
668 return ''; // Remove invalid references
669 }
670 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>';
671 },
672 $text
673 );
674
675 // Fallback: Replace [[wp_POST_ID]] format - WordPress content missing # (AI often forgets hash)
676 $text = preg_replace_callback(
677 '/\[\[wp_(\d+)\]\]/',
678 function ( $matches ) {
679 $postid = (int) $matches[1];
680 $url = get_permalink( $postid );
681 if ( empty( $url ) ) {
682 return ''; // Remove invalid references
683 }
684 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>';
685 },
686 $text
687 );
688
689 // Replace [[#POST_ID:Title]] format - wpForo content with title (ignore title, use post ID)
690 $text = preg_replace_callback(
691 '/\[\[#(\d+):([^\]]+)\]\]/',
692 function ( $matches ) {
693 $postid = (int) $matches[1];
694 $url = WPF()->post->get_url( $postid );
695 if ( empty( $url ) || $url === wpforo_home_url() ) {
696 return ''; // Remove invalid references
697 }
698 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[' . $postid . ']</a></sup>';
699 },
700 $text
701 );
702
703 // Replace [[#POST_ID]] format - wpForo content
704 $text = preg_replace_callback(
705 '/\[\[#(\d+)\]\]/',
706 function ( $matches ) {
707 $postid = (int) $matches[1];
708 $url = WPF()->post->get_url( $postid );
709 if ( empty( $url ) || $url === wpforo_home_url() ) {
710 return ''; // Remove invalid references
711 }
712 return '<sup class="wpf-ai-chat-reference"><a href="' . esc_url( $url ) . '">[' . $postid . ']</a></sup>';
713 },
714 $text
715 );
716
717 return $text;
718 }
719
720 /**
721 * Format AI response text (convert markdown to HTML)
722 *
723 * Delegates to AIMarkdown::to_html() for consistent markdown conversion.
724 *
725 * @param string $text Raw AI response text
726 * @return string Formatted HTML
727 */
728 private function format_ai_response( $text ) {
729 return AIMarkdown::to_html( $text, AIMarkdown::MODE_FRONTEND );
730 }
731
732 /**
733 * Perform local RAG search using VectorStorageManager
734 *
735 * Searches local MySQL vector storage and formats results for API.
736 *
737 * @param string $query Search query
738 * @param int $limit Maximum results
739 * @return array|WP_Error Array of RAG results or WP_Error on failure
740 */
741 private function perform_local_rag_search( $query, $limit = 5 ) {
742 // Get chatbot-specific min_score setting and apply local mode scaling
743 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
744 // so apply 1/3 of the configured threshold for local mode with 15% absolute minimum.
745 $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
746 $local_threshold = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
747 $absolute_min = 0.15; // 15% - below this, results are definitely garbage
748 $filters = [ 'min_score' => max( $local_threshold, $absolute_min ) ];
749
750 // Perform semantic search using VectorStorageManager with chatbot's score threshold
751 $search_results = WPF()->vector_storage->semantic_search( $query, $limit, $filters );
752
753 if ( is_wp_error( $search_results ) ) {
754 return $search_results;
755 }
756
757 if ( empty( $search_results['results'] ) ) {
758 return [];
759 }
760
761 // Transform results to API's expected rag_context format
762 $rag_context = [];
763 foreach ( $search_results['results'] as $result ) {
764 $content_source = $result['content_source'] ?? 'forum';
765 $is_wordpress = ( $content_source === 'wordpress' );
766
767 // Get forum name for context (only for forum content)
768 $forum_name = '';
769 if ( ! $is_wordpress && ! empty( $result['forum_id'] ) ) {
770 $forum = WPF()->forum->get_forum( $result['forum_id'] );
771 $forum_name = $forum['title'] ?? '';
772 }
773
774 // Get author name
775 $author = '';
776 if ( ! empty( $result['user_id'] ) ) {
777 $user = WPF()->member->get_member( $result['user_id'] );
778 $author = $user['display_name'] ?? '';
779 }
780
781 // Build ID with wp_ prefix for WordPress content so LLM cites correctly
782 // Use wpfval() for safe array access (avoids PHP 8 undefined key warnings)
783 $post_id = wpfval( $result, 'post_id' ) ?: wpfval( $result, 'topic_id' ) ?: '';
784 $id = $is_wordpress ? 'wp_' . $post_id : (string) $post_id;
785
786 $rag_context[] = [
787 'id' => $id,
788 'post_id' => (string) $post_id,
789 'title' => $result['title'] ?? 'Untitled',
790 'content' => $result['content'] ?? '',
791 'url' => $result['post_url'] ?? $result['url'] ?? '',
792 'score' => (float) ( $result['score'] ?? 0 ),
793 'content_type' => ! empty( $result['post_id'] ) ? 'post' : 'topic',
794 'content_source' => $content_source,
795 'permalink' => $is_wordpress ? ( $result['url'] ?? $result['post_url'] ?? '' ) : '',
796 'forum_name' => $forum_name,
797 'author' => $author,
798 ];
799 }
800
801 return $rag_context;
802 }
803
804 // =========================================================================
805 // AJAX HANDLERS
806 // =========================================================================
807
808 /**
809 * AJAX: Send a chat message
810 */
811 public function ajax_send_message() {
812 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
813
814 // Check rate limit (users have daily limits, moderators exempt)
815 $this->check_rate_limit( 'chatbot' );
816
817 if ( ! $this->user_can_chat() ) {
818 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
819 }
820
821 $conversation_id = $this->get_post_param( 'conversation_id', 0, 'int' );
822 $message = sanitize_textarea_field( wpfval( $_POST, 'message' ) );
823
824 if ( ! $conversation_id || ! $message ) {
825 $this->send_error( __( 'Missing required parameters', 'wpforo' ), 400 );
826 }
827
828 // Parse local context if provided
829 $local_context = [];
830 if ( ! empty( $_POST['local_context'] ) ) {
831 $local_context = [
832 'current_topic_title' => sanitize_text_field( wpfval( $_POST['local_context'], 'topic_title' ) ),
833 'current_forum_name' => sanitize_text_field( wpfval( $_POST['local_context'], 'forum_name' ) ),
834 'current_topic_content' => sanitize_textarea_field( wpfval( $_POST['local_context'], 'topic_content' ) ),
835 ];
836 }
837
838 $result = $this->send_message( $conversation_id, $message, $local_context );
839
840 if ( is_wp_error( $result ) ) {
841 $this->send_error( $result->get_error_message(), 500 );
842 }
843
844 $this->send_success( $result );
845 }
846
847 /**
848 * AJAX: Get user's conversations
849 */
850 public function ajax_get_conversations() {
851 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
852
853 if ( ! $this->user_can_chat() ) {
854 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
855 }
856
857 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
858 $conversations = $this->get_conversations( $max );
859
860 $this->send_success( [ 'conversations' => $conversations ] );
861 }
862
863 /**
864 * AJAX: Get messages for a conversation
865 */
866 public function ajax_get_messages() {
867 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
868
869 if ( ! $this->user_can_chat() ) {
870 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
871 }
872
873 $conversation_id = isset( $_GET['conversation_id'] ) ? intval( $_GET['conversation_id'] ) : 0;
874 if ( ! $conversation_id ) {
875 $this->send_error( __( 'Missing conversation ID', 'wpforo' ), 400 );
876 }
877
878 $messages = $this->get_messages( $conversation_id );
879 $conversation = $this->get_conversation( $conversation_id );
880
881 $this->send_success( [
882 'messages' => $messages,
883 'conversation' => $conversation,
884 ] );
885 }
886
887 /**
888 * AJAX: Create a new conversation
889 */
890 public function ajax_create_conversation() {
891 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
892
893 if ( ! $this->user_can_chat() ) {
894 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
895 }
896
897 $title = $this->get_post_param( 'title', '' );
898 $conversation_id = $this->create_conversation( $title );
899
900 if ( ! $conversation_id ) {
901 $this->send_error( __( 'Failed to create conversation', 'wpforo' ), 500 );
902 }
903
904 $conversation = $this->get_conversation( $conversation_id );
905
906 // Get welcome message and format it
907 $welcome = wpforo_setting( 'ai', 'chatbot_welcome_message' );
908 if ( empty( $welcome ) ) {
909 $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' );
910 }
911
912 // Replace {user_display_name} placeholder with actual user name
913 if ( WPF()->current_userid ) {
914 $display_name = wpforo_member( WPF()->current_userid, 'display_name' );
915 $welcome = str_replace( '{user_display_name}', esc_html( $display_name ), $welcome );
916 } else {
917 // For guests, remove the placeholder (and any space before it)
918 $welcome = str_replace( ' {user_display_name}', '', $welcome );
919 $welcome = str_replace( '{user_display_name}', '', $welcome );
920 }
921
922 // Convert line breaks to <br> and sanitize HTML
923 $welcome = nl2br( $welcome );
924 $allowed_tags = '<a><br><p><strong><em><ul><ol><li>';
925 $welcome = strip_tags( $welcome, $allowed_tags );
926
927 $this->send_success( [
928 'conversation' => $conversation,
929 'welcome_message' => $welcome,
930 ] );
931 }
932
933 /**
934 * AJAX: Delete a conversation
935 */
936 public function ajax_delete_conversation() {
937 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
938
939 if ( ! $this->user_can_chat() ) {
940 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
941 }
942
943 $conversation_id = $this->get_post_param( 'conversation_id', 0, 'int' );
944 if ( ! $conversation_id ) {
945 $this->send_error( __( 'Missing conversation ID', 'wpforo' ), 400 );
946 }
947
948 $result = $this->delete_conversation( $conversation_id );
949
950 if ( ! $result ) {
951 $this->send_error( __( 'Failed to delete conversation', 'wpforo' ), 500 );
952 }
953
954 $this->send_success( [ 'deleted' => true ] );
955 }
956
957 /**
958 * AJAX: Check if creating a new conversation would exceed the limit
959 *
960 * Returns information about which conversation would be auto-deleted
961 * if the user creates a new one.
962 */
963 public function ajax_check_conversation_limit() {
964 $this->verify_ajax_nonce( 'wpforo_ai_chatbot', 'nonce' );
965
966 if ( ! $this->user_can_chat() ) {
967 $this->send_error( __( 'You do not have permission to use the chatbot', 'wpforo' ), 403 );
968 }
969
970 $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS;
971 $conversations = $this->get_conversations( $max + 1 );
972 $count = count( $conversations );
973
974 $response = [
975 'at_limit' => $count >= $max,
976 'max_allowed' => $max,
977 'current_count' => $count,
978 ];
979
980 // If at limit, include info about the oldest conversation that would be deleted
981 if ( $count >= $max && ! empty( $conversations ) ) {
982 // Get the oldest conversation (last in the array since sorted by updated_at DESC)
983 $oldest = end( $conversations );
984 $response['oldest_conversation'] = [
985 'conversation_id' => $oldest['conversation_id'],
986 'title' => $oldest['title'] ?: __( 'New Conversation', 'wpforo' ),
987 'message_count' => $oldest['message_count'],
988 'created_at' => $oldest['created_at'],
989 'updated_at' => $oldest['updated_at'],
990 ];
991 }
992
993 $this->send_success( $response );
994 }
995 }
996