| 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 |
|