PluginProbe
wpForo Forum / 3.0.9
wpForo Forum v3.0.9
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.9, at classes/AIChatbot.php

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