is_enabled() ) { add_action( 'wp_ajax_wpforo_ai_chat_send_message', [ $this, 'ajax_send_message' ] ); add_action( 'wp_ajax_wpforo_ai_chat_get_conversations', [ $this, 'ajax_get_conversations' ] ); add_action( 'wp_ajax_wpforo_ai_chat_get_messages', [ $this, 'ajax_get_messages' ] ); add_action( 'wp_ajax_wpforo_ai_chat_create_conversation', [ $this, 'ajax_create_conversation' ] ); add_action( 'wp_ajax_wpforo_ai_chat_delete_conversation', [ $this, 'ajax_delete_conversation' ] ); add_action( 'wp_ajax_wpforo_ai_chat_check_limit', [ $this, 'ajax_check_conversation_limit' ] ); } } /** * Check if chatbot is enabled * * @return bool */ public function is_enabled() { return (bool) wpforo_setting( 'ai', 'chatbot' ); } /** * Check if current user can use chatbot * * @return bool */ public function user_can_chat() { // Check if chatbot is enabled if ( ! $this->is_enabled() ) { return false; } // Guests cannot use chatbot - login required if ( ! WPF()->current_userid ) { return false; } // Check allowed usergroups $allowed_groups = wpforo_setting( 'ai', 'chatbot_allowed_groups' ); if ( ! empty( $allowed_groups ) && is_array( $allowed_groups ) ) { $user_groupid = WPF()->current_user_groupid; if ( ! in_array( (int) $user_groupid, $allowed_groups, false ) ) { return false; } } return true; } /** * Get conversations for current user * * @param int $limit Maximum number of conversations * @return array */ public function get_conversations( $limit = 10 ) { $userid = WPF()->current_userid; $table = WPF()->tables->ai_chat_conversations; $sql = WPF()->db->prepare( "SELECT * FROM `{$table}` WHERE userid = %d ORDER BY updated_at DESC LIMIT %d", $userid, $limit ); $conversations = WPF()->db->get_results( $sql, ARRAY_A ); // Parse key_facts JSON foreach ( $conversations as &$conv ) { $conv['key_facts'] = $conv['key_facts'] ? json_decode( $conv['key_facts'], true ) : []; } return $conversations ?: []; } /** * Get a specific conversation * * @param int $conversation_id * @return array|null */ public function get_conversation( $conversation_id ) { $userid = WPF()->current_userid; $table = WPF()->tables->ai_chat_conversations; $sql = WPF()->db->prepare( "SELECT * FROM `{$table}` WHERE conversation_id = %d AND userid = %d", $conversation_id, $userid ); $conversation = WPF()->db->get_row( $sql, ARRAY_A ); if ( $conversation ) { $conversation['key_facts'] = $conversation['key_facts'] ? json_decode( $conversation['key_facts'], true ) : []; } return $conversation; } /** * Create a new conversation * * @param string $title Optional title * @return int|false Conversation ID or false on failure */ public function create_conversation( $title = '' ) { $userid = WPF()->current_userid; // Check max conversations limit $max = intval( wpforo_setting( 'ai', 'chatbot_max_conversations' ) ) ?: self::DEFAULT_MAX_CONVERSATIONS; $existing = $this->get_conversations( $max + 1 ); // Delete oldest conversations if over limit if ( count( $existing ) >= $max ) { $to_delete = array_slice( $existing, $max - 1 ); foreach ( $to_delete as $conv ) { $this->delete_conversation( $conv['conversation_id'] ); } } $table = WPF()->tables->ai_chat_conversations; $result = WPF()->db->insert( $table, [ 'userid' => $userid, 'title' => $title ?: '', 'message_count' => 0, 'total_tokens' => 0, 'total_credits' => 0, ], [ '%d', '%s', '%d', '%d', '%d' ] ); return $result ? WPF()->db->insert_id : false; } /** * Update conversation context (summary and key facts) * * @param int $conversation_id * @param string $summary * @param array $key_facts * @return bool */ public function update_conversation_context( $conversation_id, $summary, $key_facts = [] ) { $userid = WPF()->current_userid; $table = WPF()->tables->ai_chat_conversations; $result = WPF()->db->update( $table, [ 'running_summary' => $summary, 'key_facts' => json_encode( $key_facts ), ], [ 'conversation_id' => $conversation_id, 'userid' => $userid, ], [ '%s', '%s' ], [ '%d', '%d' ] ); return $result !== false; } /** * Delete a conversation and its messages * * @param int $conversation_id * @return bool */ public function delete_conversation( $conversation_id ) { $userid = WPF()->current_userid; // Delete messages first $messages_table = WPF()->tables->ai_chat_messages; WPF()->db->delete( $messages_table, [ 'conversation_id' => $conversation_id ], [ '%d' ] ); // Delete conversation $conv_table = WPF()->tables->ai_chat_conversations; $result = WPF()->db->delete( $conv_table, [ 'conversation_id' => $conversation_id, 'userid' => $userid, ], [ '%d', '%d' ] ); return $result !== false; } /** * Get messages for a conversation * * @param int $conversation_id * @param int $limit * @return array */ public function get_messages( $conversation_id, $limit = 100 ) { // Verify conversation ownership $conversation = $this->get_conversation( $conversation_id ); if ( ! $conversation ) { return []; } $table = WPF()->tables->ai_chat_messages; $sql = WPF()->db->prepare( "SELECT * FROM `{$table}` WHERE conversation_id = %d ORDER BY created_at ASC LIMIT %d", $conversation_id, $limit ); $messages = WPF()->db->get_results( $sql, ARRAY_A ); // Parse sources_json and format assistant messages foreach ( $messages as &$msg ) { $msg['sources'] = $msg['sources_json'] ? json_decode( $msg['sources_json'], true ) : []; unset( $msg['sources_json'] ); // Convert post_id to URL in sources (regenerate URLs from stored post_ids) if ( ! empty( $msg['sources'] ) ) { foreach ( $msg['sources'] as &$source ) { if ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) { $content_source = $source['content_source'] ?? 'wpforo'; if ( $content_source === 'wordpress' ) { // WordPress content - use get_permalink() or stored permalink if ( ! empty( $source['permalink'] ) ) { $source['url'] = $source['permalink']; } else { $source['url'] = get_permalink( (int) $source['post_id'] ); } } else { // wpForo content - use WPF()->post->get_url() $source['url'] = WPF()->post->get_url( (int) $source['post_id'] ); } } } } // Format assistant messages (convert markdown to HTML) if ( $msg['role'] === 'assistant' && ! empty( $msg['content'] ) ) { // Replace [NO_FORUM_CONTENT] placeholder with custom message $original_content = $msg['content']; $msg['content'] = $this->replace_no_content_placeholder( $msg['content'] ); $has_no_content = ( $msg['content'] !== $original_content ); // Skip markdown formatting for no-content messages (they may contain HTML) if ( ! $has_no_content ) { // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links // (link markers must be processed AFTER format_ai_response to avoid HTML escaping) $msg['content'] = $this->format_ai_response( $msg['content'] ); $msg['content'] = $this->replace_post_link_markers( $msg['content'] ); } } } return $messages ?: []; } /** * Get recent messages for context * * @param int $conversation_id * @param int $limit * @return array */ public function get_recent_messages( $conversation_id, $limit = 3 ) { $table = WPF()->tables->ai_chat_messages; $sql = WPF()->db->prepare( "SELECT role, content FROM `{$table}` WHERE conversation_id = %d ORDER BY created_at DESC LIMIT %d", $conversation_id, $limit ); $messages = WPF()->db->get_results( $sql, ARRAY_A ); // Reverse to get chronological order return array_reverse( $messages ?: [] ); } /** * Add a message to conversation * * @param int $conversation_id * @param string $role 'user' or 'assistant' * @param string $content * @param array $metadata Optional metadata (tokens, credits, sources, etc.) * @return int|false Message ID or false on failure */ public function add_message( $conversation_id, $role, $content, $metadata = [] ) { $table = WPF()->tables->ai_chat_messages; $data = [ 'conversation_id' => $conversation_id, 'role' => $role, 'content' => $content, 'tokens_used' => $metadata['tokens_used'] ?? 0, 'credits_spent' => $metadata['credits_spent'] ?? 0, 'quality_tier' => $metadata['quality_tier'] ?? 'fast', 'sources_count' => $metadata['sources_count'] ?? 0, 'sources_json' => ! empty( $metadata['sources'] ) ? json_encode( $metadata['sources'] ) : null, 'context_updated' => $metadata['context_updated'] ?? 0, ]; $result = WPF()->db->insert( $table, $data, [ '%d', '%s', '%s', '%d', '%d', '%s', '%d', '%s', '%d' ] ); if ( $result ) { // Update conversation stats $this->update_conversation_stats( $conversation_id, $content, $metadata['tokens_used'] ?? 0, $metadata['credits_spent'] ?? 0 ); return WPF()->db->insert_id; } return false; } /** * Update conversation statistics * * @param int $conversation_id * @param string $message_content For title generation * @param int $tokens * @param int $credits */ private function update_conversation_stats( $conversation_id, $message_content, $tokens, $credits ) { $table = WPF()->tables->ai_chat_conversations; $conversation = $this->get_conversation( $conversation_id ); if ( ! $conversation ) { return; } $updates = [ 'message_count' => $conversation['message_count'] + 1, 'total_tokens' => $conversation['total_tokens'] + $tokens, 'total_credits' => $conversation['total_credits'] + $credits, 'last_message_at' => current_time( 'mysql', true ), // UTC for timezone conversion ]; $formats = [ '%d', '%d', '%d', '%s' ]; // Set title from first user message if empty (25 chars max) if ( empty( $conversation['title'] ) && $conversation['message_count'] == 0 ) { $title = trim( strip_tags( $message_content ) ); $updates['title'] = mb_strlen( $title ) > 25 ? mb_substr( $title, 0, 25 ) . '...' : $title; $formats[] = '%s'; } WPF()->db->update( $table, $updates, [ 'conversation_id' => $conversation_id ], $formats, [ '%d' ] ); } /** * Send message to AI and get response * * @param int $conversation_id * @param string $message * @param array $local_context Optional local page context * @return array|WP_Error */ public function send_message( $conversation_id, $message, $local_context = [] ) { $conversation = $this->get_conversation( $conversation_id ); if ( ! $conversation ) { return new \WP_Error( 'not_found', __( 'Conversation not found', 'wpforo' ) ); } // Build conversation context for API $recent_messages = $this->get_recent_messages( $conversation_id, 3 ); $conversation_context = [ 'running_summary' => $conversation['running_summary'] ?? '', 'key_facts' => $conversation['key_facts'] ?? [], 'recent_messages' => $recent_messages, 'message_count' => (int) $conversation['message_count'], ]; // Get settings $quality = wpforo_setting( 'ai', 'chatbot_quality' ) ?: 'fast'; $use_rag = (bool) wpforo_setting( 'ai', 'chatbot_use_rag' ); $use_local = (bool) wpforo_setting( 'ai', 'chatbot_use_local_context' ); $context_threshold = (int) wpforo_setting( 'ai', 'chatbot_context_update_threshold' ) ?: self::DEFAULT_CONTEXT_UPDATE_THRESHOLD; $no_content_message = wpforo_setting( 'ai', 'chatbot_no_content_message' ) ?: ''; $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' ); // Get response language $language = WPF()->ai_client->get_user_language( null, 'chatbot_language' ); // Build request data $request_data = [ 'message' => $message, 'conversation_context' => $conversation_context, 'settings' => [ 'quality' => $quality, 'language' => $language, 'use_rag' => $use_rag, 'context_update_threshold' => $context_threshold, 'no_content_message' => $no_content_message, ], ]; // Add min_score to settings if set (for cloud RAG filtering) // Converted from percentage (30) to decimal (0.3) for API if ( $min_score_setting > 0 ) { $request_data['settings']['min_score'] = $min_score_setting / 100; } // Add custom knowledge parameters (Business+ cloud mode only) if ( WPF()->ai_client->is_custom_knowledge_enabled() ) { $request_data['settings']['include_custom_knowledge'] = true; $request_data['settings']['knowledge_priority'] = [ 'chat_priority' => WPF()->ai_client->get_knowledge_priorities( 'chat' ), ]; } // Add local context if enabled if ( $use_local && ! empty( $local_context ) ) { $request_data['local_context'] = $local_context; } // Check storage mode and perform local RAG search if needed // When in local mode, WordPress performs the vector search and sends results to API if ( $use_rag && WPF()->vector_storage->is_local_mode() ) { $rag_results = $this->perform_local_rag_search( $message ); if ( ! is_wp_error( $rag_results ) && ! empty( $rag_results ) ) { $request_data['rag_context'] = $rag_results; } } // Call chat API via AIClient $response = WPF()->ai_client->api_post( '/chat/message', $request_data, 60 ); if ( is_wp_error( $response ) ) { return $response; } // Store user message $this->add_message( $conversation_id, 'user', $message ); // Store assistant response $credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3, 'premium' => 4 ]; $credits = $credits_map[ $quality ] ?? 2; $this->add_message( $conversation_id, 'assistant', $response['response'], [ 'tokens_used' => $response['tokens_used'] ?? 0, 'credits_spent' => $credits, 'quality_tier' => $quality, 'sources_count' => count( $response['sources'] ?? [] ), 'sources' => $response['sources'] ?? [], 'context_updated' => ! empty( $response['context_update']['should_update'] ) ? 1 : 0, ] ); // Update conversation context if API returned updates if ( ! empty( $response['context_update']['should_update'] ) ) { $this->update_conversation_context( $conversation_id, $response['context_update']['summary'] ?? '', $response['context_update']['key_facts'] ?? [] ); } // Replace [NO_FORUM_CONTENT] placeholder if present, then format $response_text = $this->replace_no_content_placeholder( $response['response'] ); $has_no_content = ( $response_text !== $response['response'] ); // Format markdown first, then convert [[#POST_ID:Title]] markers to clickable links // (link markers must be processed AFTER format_ai_response to avoid HTML escaping) $formatted_response = $has_no_content ? $response_text : $this->format_ai_response( $response_text ); $formatted_response = $has_no_content ? $formatted_response : $this->replace_post_link_markers( $formatted_response ); // Convert post_id to url in sources (API sends post_id, PHP generates URL) $sources = $has_no_content ? [] : ( $response['sources'] ?? [] ); foreach ( $sources as &$source ) { $content_source = $source['content_source'] ?? 'wpforo'; if ( $content_source === 'custom_knowledge' ) { // Custom knowledge - no URL, just ensure proper labeling $source['url'] = ''; $source['content_type_label'] = __( 'Knowledge Base', 'wpforo' ); } elseif ( ! empty( $source['post_id'] ) && empty( $source['url'] ) ) { if ( $content_source === 'wordpress' ) { // WordPress content - use get_permalink() or stored permalink if ( ! empty( $source['permalink'] ) ) { $source['url'] = $source['permalink']; } else { $source['url'] = get_permalink( (int) $source['post_id'] ); } } else { // wpForo content - use WPF()->post->get_url() $source['url'] = WPF()->post->get_url( (int) $source['post_id'] ); } } } return [ 'response' => $formatted_response, 'sources' => $sources, ]; } /** * Replace [NO_FORUM_CONTENT] placeholder with custom message * * @param string $text Response text * @return string Text with placeholder replaced (or original if no placeholder) */ private function replace_no_content_placeholder( $text ) { if ( strpos( $text, '[NO_FORUM_CONTENT]' ) === false ) { return $text; } $custom_message = wpforo_setting( 'ai', 'chatbot_no_content_message' ); if ( ! empty( $custom_message ) ) { // Replace placeholders with actual URLs $add_topic_url = wpforo_home_url( '/add-topic/' ); $custom_message = str_replace( '{add_topic_url}', esc_url( $add_topic_url ), $custom_message ); // Use custom message with HTML preserved (sanitize with allowed tags) $allowed_tags = '