PluginProbe
wpForo Forum / 3.2.0
wpForo Forum v3.2.0
3.2.0 3.1.7 3.1.6 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 All 140 releases
← All changes | classes/AIClient.php +663 -6 3.0.9 → 3.2.0 View file →
@@ -66,8 +66,9 @@
66 66
67 67 // Register admin-only AJAX handlers
68 68 if ( is_admin() ) {
69 69 add_action( 'wp_ajax_wpforo_ai_get_rag_status', [ $this, 'ajax_get_rag_status' ] );
70 + add_action( 'wp_ajax_wpforo_ai_get_indexing_breakdown', [ $this, 'ajax_get_indexing_breakdown' ] );
70 71 add_action( 'wp_ajax_wpforo_ai_cancel_cloud_indexing', [ $this, 'ajax_cancel_cloud_indexing' ] );
71 72 add_action( 'wp_ajax_wpforo_ai_cleanup_indexing_session', [ $this, 'ajax_cleanup_indexing_session' ] );
72 73 add_action( 'wp_ajax_wpforo_ai_action', [ $this, 'ajax_generic_action' ] );
73 74 add_action( 'wp_ajax_wpforo_ai_save_storage_mode', [ $this, 'ajax_save_storage_mode' ] );
@@ -84,8 +85,16 @@
84 85 add_action( 'wp_ajax_wpforo_ai_activate_paddle_transaction', [ $this, 'ajax_activate_paddle_transaction' ] );
85 86 add_action( 'wp_ajax_wpforo_ai_search_bot_users', [ $this, 'ajax_search_bot_users' ] );
86 87 add_action( 'wp_ajax_wpforo_ai_request_bonus_credits', [ $this, 'ajax_request_bonus_credits' ] );
87 88
89 + // Custom Knowledge AJAX handlers
90 + add_action( 'wp_ajax_wpforo_ai_add_knowledge', [ $this, 'ajax_add_knowledge' ] );
91 + add_action( 'wp_ajax_wpforo_ai_delete_knowledge', [ $this, 'ajax_delete_knowledge' ] );
92 + add_action( 'wp_ajax_wpforo_ai_save_knowledge_settings', [ $this, 'ajax_save_knowledge_priorities' ] );
93 + add_action( 'wp_ajax_wpforo_ai_get_knowledge_settings', [ $this, 'ajax_get_knowledge_settings' ] );
94 + add_action( 'wp_ajax_wpforo_ai_get_knowledge_files', [ $this, 'ajax_get_knowledge_files' ] );
95 + add_action( 'wp_ajax_wpforo_ai_get_job_status', [ $this, 'ajax_get_job_status' ] );
96 +
88 97 // Register privacy policy content for AI features
89 98 add_action( 'admin_init', [ $this, 'register_privacy_policy_content' ] );
90 99
91 100 // Self-heal stalled WP-Cron on wpForo AI admin page loads.
@@ -182,11 +191,88 @@
182 191 add_filter( 'wpforo_form_fields', [ $this, 'add_ai_suggestions_after_title' ] );
183 192
184 193 // Disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled
185 194 add_filter( 'wpforo_topic_suggestion', [ $this, 'filter_built_in_suggestions' ] );
195 +
196 + // Cron lifecycle: schedule/unschedule the recurring AI maintenance crons
197 + // based on AI service connection state. Prevents stale events from
198 + // piling up in `wp_options.cron` on installs that never enabled AI.
199 + add_action( 'wpforo_ai_tenant_registered', [ $this, 'register_ai_crons' ] );
200 + add_action( 'wpforo_ai_tenant_disconnected', [ $this, 'unregister_ai_crons' ] );
186 201 }
187 202
188 203 /**
204 + * Schedule all recurring AI-related crons.
205 + *
206 + * Called on tenant connect (wpforo_ai_tenant_registered). Idempotent —
207 + * each helper skips if already scheduled. Safe to call from plugin
208 + * upgrade migrations via sync_cron_state().
209 + */
210 + public function register_ai_crons() {
211 + $this->schedule_cache_cleanup();
212 + $this->schedule_daily_subscription_sync();
213 +
214 + if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) {
215 + WPF()->ai_content_moderation->schedule_moderation_cleanup();
216 + }
217 + if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) {
218 + WPF()->vector_storage->schedule_cron_jobs();
219 + }
220 + if ( isset( WPF()->task_manager ) && WPF()->task_manager ) {
221 + WPF()->task_manager->schedule_cron_jobs();
222 + }
223 +
224 + $this->log_info( 'ai_crons_registered' );
225 + }
226 +
227 + /**
228 + * Unschedule every recurring AI-related cron.
229 + *
230 + * Called on tenant disconnect (wpforo_ai_tenant_disconnected) and on
231 + * plugin upgrade when not connected, so users who never enabled AI (or
232 + * who disconnected) do not see stale events accumulating in wp_cron.
233 + *
234 + * Single-event crons (wpforo_ai_execute_task[_for_topic], _process_batch,
235 + * _process_queue*, _process_wp_batch) are not blanket-cleared here — they
236 + * are managed per-task on the AI side and never get scheduled for users
237 + * who do not use AI features.
238 + */
239 + public function unregister_ai_crons() {
240 + $this->unschedule_cache_cleanup();
241 + $this->unschedule_daily_subscription_sync();
242 + $this->unschedule_pending_topics_indexing();
243 +
244 + if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) {
245 + WPF()->ai_content_moderation->unschedule_moderation_cleanup();
246 + }
247 + if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) {
248 + WPF()->vector_storage->unschedule_cron_jobs();
249 + }
250 + if ( isset( WPF()->task_manager ) && WPF()->task_manager ) {
251 + WPF()->task_manager->unschedule_cron_jobs();
252 + }
253 +
254 + // AILogs cleanup is scheduled lazily on first log insert; clear it
255 + // too so the wp_cron option stays clean for users who never reconnect.
256 + wp_clear_scheduled_hook( 'wpforo_ai_logs_cleanup' );
257 +
258 + $this->log_info( 'ai_crons_unregistered' );
259 + }
260 +
261 + /**
262 + * Idempotent reconciler: ensures the AI cron set matches the current
263 + * connection state. Called from plugin upgrade so existing installs that
264 + * accumulated AI crons without ever connecting get cleaned up.
265 + */
266 + public function sync_cron_state() {
267 + if ( $this->is_connected() ) {
268 + $this->register_ai_crons();
269 + } else {
270 + $this->unregister_ai_crons();
271 + }
272 + }
273 +
274 + /**
189 275 * Get API base URL
190 276 *
191 277 * @return string API base URL
192 278 */
@@ -616,8 +702,9 @@
616 702 'wordpress_content_indexing' => [ 'plan' => 'business' ],
617 703 'custom_post_types_indexing' => [ 'plan' => 'business' ],
618 704 'woocommerce_products_indexing' => [ 'plan' => 'business' ],
619 705 'vector_db_cloud_storage' => [ 'plan' => 'business' ],
706 + 'custom_knowledge' => [ 'plan' => 'business' ],
620 707
621 708 // Enterprise Plan Features
622 709 'developer_features' => [ 'plan' => 'enterprise' ],
623 710 'rest_api_access' => [ 'plan' => 'enterprise' ],
@@ -926,8 +1013,25 @@
926 1013 $this->send_success( $status );
927 1014 }
928 1015
929 1016 /**
1017 + * AJAX handler for getting indexing status breakdown (private/unapproved counts)
1018 + *
1019 + * Returns cached breakdown data (1-day TTL) for displaying excluded topics info.
1020 + * Loaded asynchronously after page load to avoid slow initial page renders.
1021 + *
1022 + * @return void
1023 + */
1024 + public function ajax_get_indexing_breakdown() {
1025 + $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1026 +
1027 + $storage_manager = WPF()->vector_storage;
1028 + $breakdown = $storage_manager->get_indexing_status_breakdown();
1029 +
1030 + $this->send_success( $breakdown );
1031 + }
1032 +
1033 + /**
930 1034 * Ask the backend to stop any in-flight cloud indexing for this tenant.
931 1035 *
932 1036 * Sends POST /v1/rag/cancel, which sets a cancellation flag on the
933 1037 * tenant record with a ~10 minute TTL. The backend reads the flag
@@ -1064,8 +1168,11 @@
1064 1168 if ( delete_transient( 'wpforo_ai_rag_status_' . $board_id ) ) {
1065 1169 $summary['transients_deleted']++;
1066 1170 }
1067 1171
1172 + // Clear indexing breakdown cache so UI shows fresh counts
1173 + WPF()->vector_storage->clear_indexing_status_breakdown_cache();
1174 +
1068 1175 // Tell the backend to drop any in-flight cloud image_worker items.
1069 1176 // Safe in local mode: backend simply sets indexing_cancel_until on
1070 1177 // the tenant record with no other side effects. Errors are
1071 1178 // non-fatal — WP-side cleanup has already succeeded.
@@ -2788,8 +2895,16 @@
2788 2895 if ( $min_score_setting > 0 ) {
2789 2896 $data['min_score'] = $min_score_setting / 100; // Convert percentage to 0-1
2790 2897 }
2791 2898
2899 + // Add custom knowledge parameters (Business+ plans)
2900 + if ( $this->is_custom_knowledge_enabled() ) {
2901 + $data['include_custom_knowledge'] = true;
2902 + $data['knowledge_priority'] = [
2903 + 'search_priority' => $this->get_knowledge_priorities( 'search' ),
2904 + ];
2905 + }
2906 +
2792 2907 $response = $this->post( '/search/semantic', $data );
2793 2908
2794 2909 if ( is_wp_error( $response ) ) {
2795 2910 $this->log_error( 'semantic_search_failed', $response->get_error_message() );
@@ -3481,8 +3596,11 @@
3481 3596
3482 3597 $wp_post = get_post( (int) $wp_post_id );
3483 3598 if ( ! $wp_post || $wp_post->post_status !== 'publish' ) continue;
3484 3599
3600 + // Skip password-protected posts unless user has entered the password
3601 + if ( post_password_required( $wp_post ) ) continue;
3602 +
3485 3603 // Use real WP post_type (post, page, product, etc.)
3486 3604 $post_type_obj = get_post_type_object( $wp_post->post_type );
3487 3605 $post_type_label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst( $wp_post->post_type );
3488 3606
@@ -3524,8 +3642,47 @@
3524 3642 'author_url' => '',
3525 3643 'created' => $wp_post->post_date ? date( 'Y-m-d H:i', strtotime( $wp_post->post_date ) ) : '',
3526 3644 'created_ago' => $wp_post->post_date ? human_time_diff( strtotime( $wp_post->post_date ) ) . ' ago' : '',
3527 3645 ];
3646 + } elseif ( $content_source === 'custom_knowledge' ) {
3647 + // ── Custom Knowledge result (Business+ cloud mode only) ──
3648 + $title = wpfval( $result, 'title' ) ?: wpfval( $result, 'metadata', 'title' ) ?: wpforo_phrase( 'Knowledge Base', false );
3649 + $content = wpfval( $result, 'excerpt' ) ?: wpfval( $result, 'metadata', 'content_preview' ) ?: '';
3650 + $content = $this->clean_content_for_search_display( $content );
3651 +
3652 + $score = wpfval( $result, 'score' ) ?: 0;
3653 + $score_percent = round( $score * 100 );
3654 +
3655 + if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) {
3656 + continue;
3657 + }
3658 +
3659 + if ( $score_percent >= $threshold_excellent ) {
3660 + $relevance_label = wpforo_phrase( 'Excellent match', false );
3661 + } elseif ( $score_percent >= $threshold_good ) {
3662 + $relevance_label = wpforo_phrase( 'Good match', false );
3663 + } elseif ( $score_percent >= $threshold_relevant ) {
3664 + $relevance_label = wpforo_phrase( 'Relevant', false );
3665 + } else {
3666 + $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3667 + }
3668 +
3669 + $enriched_results[] = [
3670 + 'title' => $title,
3671 + 'url' => '', // Custom knowledge has no URL
3672 + 'content' => $content,
3673 + 'score' => $score_percent,
3674 + 'relevance_label' => $relevance_label,
3675 + 'content_source' => 'custom_knowledge',
3676 + 'post_type_label' => wpforo_phrase( 'Knowledge Base', false ),
3677 + 'post_id' => 0,
3678 + 'forum_title' => '',
3679 + 'forum_url' => '',
3680 + 'author_name' => '',
3681 + 'author_url' => '',
3682 + 'created' => '',
3683 + 'created_ago' => '',
3684 + ];
3528 3685 } else {
3529 3686 // ── Forum result ──
3530 3687 $topic_id = wpfval( $result, 'topic_id' ) ?: wpfval( $result, 'metadata', 'thread_id' );
3531 3688
@@ -3718,8 +3875,21 @@
3718 3875
3719 3876 // Update total to reflect filtered results count
3720 3877 $filtered_total = count( $enriched_results );
3721 3878
3879 + // Sanitize AI enhancement output to prevent XSS
3880 + if ( $ai_enhancement ) {
3881 + if ( isset( $ai_enhancement['summary'] ) ) {
3882 + $ai_enhancement['summary'] = wpforo_kses( (string) $ai_enhancement['summary'] );
3883 + }
3884 + if ( isset( $ai_enhancement['quick_answer'] ) ) {
3885 + $ai_enhancement['quick_answer'] = wpforo_kses( (string) $ai_enhancement['quick_answer'] );
3886 + }
3887 + if ( isset( $ai_enhancement['recommendations_html'] ) ) {
3888 + $ai_enhancement['recommendations_html'] = wpforo_kses( (string) $ai_enhancement['recommendations_html'] );
3889 + }
3890 + }
3891 +
3722 3892 // Return enriched results with AI enhancement
3723 3893 wp_send_json_success( [
3724 3894 'results' => $enriched_results,
3725 3895 'total' => $filtered_total,
@@ -6929,8 +7099,15 @@
6929 7099 'message' => wpforo_phrase( 'Post not found', false )
6930 7100 ], 404 );
6931 7101 }
6932 7102
7103 + // SECURITY: Check if user can view this post before allowing translation
7104 + if ( ! WPF()->post->view_access( $post ) ) {
7105 + wp_send_json_error( [
7106 + 'message' => wpforo_phrase( 'You do not have permission to view this content', false )
7107 + ], 403 );
7108 + }
7109 +
6933 7110 // Get the rendered HTML content using output buffering
6934 7111 // (wpforo_content echoes instead of returning)
6935 7112 ob_start();
6936 7113 wpforo_content( $post );
@@ -6960,10 +7137,11 @@
6960 7137 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
6961 7138 ] );
6962 7139 }
6963 7140 // Return cached translation (no credits used)
7141 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
6964 7142 wp_send_json_success( [
6965 - 'translated_content' => wpfval( $cached_result, 'translated_content' ) ?: '',
7143 + 'translated_content' => wpforo_kses( (string) wpfval( $cached_result, 'translated_content' ) ),
6966 7144 'source_language' => wpfval( $cached_result, 'source_language' ) ?: 'auto',
6967 7145 'target_language' => $target_language,
6968 7146 'credits_used' => 0,
6969 7147 'cached' => true,
@@ -7019,10 +7197,11 @@
7019 7197 ];
7020 7198 $this->set_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key, $cache_data, 0, $post_id );
7021 7199
7022 7200 // Return translated content
7201 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
7023 7202 wp_send_json_success( [
7024 - 'translated_content' => wpfval( $result, 'translated_content' ) ?: '',
7203 + 'translated_content' => wpforo_kses( (string) wpfval( $result, 'translated_content' ) ),
7025 7204 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto',
7026 7205 'target_language' => $target_language,
7027 7206 'credits_used' => $credits_used,
7028 7207 'cached' => false,
@@ -7182,8 +7361,15 @@
7182 7361 'message' => wpforo_phrase( 'Topic not found', false )
7183 7362 ], 404 );
7184 7363 }
7185 7364
7365 + // SECURITY: Check if user can view this topic before allowing summarization
7366 + if ( ! WPF()->topic->view_access( $topic ) ) {
7367 + wp_send_json_error( [
7368 + 'message' => wpforo_phrase( 'You do not have permission to view this content', false )
7369 + ], 403 );
7370 + }
7371 +
7186 7372 // Get summary style from settings or request
7187 7373 $style = sanitize_text_field( wpfval( $_POST, 'style' ) );
7188 7374 if ( empty( $style ) ) {
7189 7375 $style = wpfval( WPF()->settings->ai, 'topic_summary_style' ) ?: 'detailed';
@@ -7292,13 +7478,14 @@
7292 7478 }
7293 7479
7294 7480 // Return cached summary (no credits used)
7295 7481 // Process link markers to convert [[#POST_ID]] to clickable links
7296 - $cached_summary = wpfval( $cached_result, 'summary' ) ?: '';
7482 + $cached_summary = (string) wpfval( $cached_result, 'summary' );
7297 7483 $cached_summary = $this->replace_summary_link_markers( $cached_summary, $topicid );
7298 7484
7485 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
7299 7486 wp_send_json_success( [
7300 - 'summary' => $cached_summary,
7487 + 'summary' => wpforo_kses( $cached_summary ),
7301 7488 'style' => wpfval( $cached_result, 'style' ) ?: $style,
7302 7489 'topic_id' => $topicid,
7303 7490 'reply_count' => $reply_count,
7304 7491 'total_posts_count' => $total_posts_count,
@@ -7337,9 +7524,9 @@
7337 7524 ], 500 );
7338 7525 }
7339 7526
7340 7527 // Get raw summary and store in cache (keep raw with link markers for re-processing)
7341 - $raw_summary = wpfval( $result, 'summary' ) ?: '';
7528 + $raw_summary = (string) wpfval( $result, 'summary' );
7342 7529 // Strip markdown code fence wrappers (```html ... ```) that LLMs sometimes add around HTML output
7343 7530 $raw_summary = preg_replace( '/^\s*```\w*\s*\n([\s\S]*?)\n\s*```\s*$/s', '$1', $raw_summary );
7344 7531 $credits_used = wpfval( $result, 'credits_used' ) ?: 1;
7345 7532
@@ -7370,10 +7557,11 @@
7370 7557 // Process link markers to convert [[#POST_ID]] to clickable links
7371 7558 $processed_summary = $this->replace_summary_link_markers( $raw_summary, $topicid );
7372 7559
7373 7560 // Return summary with clickable links
7561 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
7374 7562 wp_send_json_success( [
7375 - 'summary' => $processed_summary,
7563 + 'summary' => wpforo_kses( $processed_summary ),
7376 7564 'style' => wpfval( $result, 'style' ) ?: $style,
7377 7565 'topic_id' => $topicid,
7378 7566 'reply_count' => $reply_count,
7379 7567 'total_posts_count' => $total_posts_count,
@@ -7624,8 +7812,17 @@
7624 7812 if ( $accessible_forumids !== null ) {
7625 7813 $payload['accessible_forumids'] = $accessible_forumids;
7626 7814 }
7627 7815
7816 + // Add custom knowledge parameters (Business+ plans)
7817 + if ( $this->is_custom_knowledge_enabled() ) {
7818 + $payload['include_custom_knowledge'] = true;
7819 + $payload['knowledge_priority'] = [
7820 + 'search_priority' => $this->get_knowledge_priorities( 'search' ),
7821 + 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ),
7822 + ];
7823 + }
7824 +
7628 7825 // Make API request to suggestions endpoint
7629 7826 $response = $this->post( '/suggestions/suggest', $payload );
7630 7827
7631 7828 if ( is_wp_error( $response ) ) {
@@ -8770,8 +8967,16 @@
8770 8967 'knowledge_source' => $knowledge_source,
8771 8968 'response_language' => $response_language,
8772 8969 ];
8773 8970
8971 + // Add custom knowledge params if enabled (Business+ only, cloud storage only)
8972 + if ( $this->is_custom_knowledge_enabled() ) {
8973 + $request_body['include_custom_knowledge'] = true;
8974 + $request_body['knowledge_priority'] = [
8975 + 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ),
8976 + ];
8977 + }
8978 +
8774 8979 // Make API request to /tasks/generate endpoint
8775 8980 $response = wp_remote_post( $this->api_base_url . '/tasks/generate', [
8776 8981 'timeout' => 60,
8777 8982 'headers' => [
@@ -9472,6 +9677,458 @@
9472 9677 }
9473 9678 }
9474 9679
9475 9680 return array_values( $url_map );
9681 + }
9682 +
9683 + // =========================================================================
9684 + // CUSTOM KNOWLEDGE AJAX HANDLERS
9685 + // =========================================================================
9686 +
9687 + /**
9688 + * AJAX handler for adding custom knowledge
9689 + *
9690 + * Sends file URL to backend for processing and indexing.
9691 + * Endpoint: POST /v1/knowledge/ingest
9692 + */
9693 + public function ajax_add_knowledge() {
9694 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9695 +
9696 + if ( ! current_user_can( 'manage_options' ) ) {
9697 + wp_send_json_error( [
9698 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9699 + ], 403 );
9700 + }
9701 +
9702 + if ( ! $this->is_feature_available( 'custom_knowledge' ) ) {
9703 + wp_send_json_error( [
9704 + 'message' => wpforo_phrase( 'Custom knowledge requires Business plan or higher', false )
9705 + ], 403 );
9706 + }
9707 +
9708 + $file_url = isset( $_POST['file_url'] ) ? esc_url_raw( trim( $_POST['file_url'] ) ) : '';
9709 + $file_type = isset( $_POST['file_type'] ) ? sanitize_key( $_POST['file_type'] ) : 'text';
9710 + $file_name = isset( $_POST['file_name'] ) ? sanitize_text_field( trim( $_POST['file_name'] ) ) : '';
9711 +
9712 + if ( empty( $file_url ) ) {
9713 + wp_send_json_error( [
9714 + 'message' => wpforo_phrase( 'File URL is required', false )
9715 + ], 400 );
9716 + }
9717 +
9718 + $valid_types = [ 'json', 'markdown', 'text', 'pdf' ];
9719 + if ( ! in_array( $file_type, $valid_types, true ) ) {
9720 + $file_type = 'text';
9721 + }
9722 +
9723 + // Build request body - backend expects 'name' not 'file_name'
9724 + $request_body = [
9725 + 'file_url' => $file_url,
9726 + 'file_type' => $file_type,
9727 + ];
9728 + if ( ! empty( $file_name ) ) {
9729 + $request_body['name'] = $file_name;
9730 + }
9731 +
9732 + $response = $this->post( '/knowledge/ingest', $request_body );
9733 +
9734 + if ( is_wp_error( $response ) ) {
9735 + wp_send_json_error( [
9736 + 'message' => $response->get_error_message()
9737 + ], 400 );
9738 + }
9739 +
9740 + $this->log_info( 'knowledge_added', [
9741 + 'file_url' => $file_url,
9742 + 'file_type' => $file_type,
9743 + 'name' => $file_name
9744 + ] );
9745 +
9746 + // Merge backend response fields into success response
9747 + // JS polling expects: async, file_id, name at top level
9748 + $success_data = [
9749 + 'message' => wpforo_phrase( 'Knowledge file added. Processing will begin shortly.', false ),
9750 + ];
9751 +
9752 + // Pass through key fields from backend response
9753 + if ( is_array( $response ) ) {
9754 + if ( ! empty( $response['file_id'] ) ) {
9755 + $success_data['file_id'] = $response['file_id'];
9756 + }
9757 + if ( ! empty( $response['async'] ) ) {
9758 + $success_data['async'] = true;
9759 + }
9760 + if ( ! empty( $response['name'] ) ) {
9761 + $success_data['name'] = $response['name'];
9762 + }
9763 + if ( isset( $response['credits_remaining'] ) ) {
9764 + $success_data['credits_remaining'] = $response['credits_remaining'];
9765 + }
9766 + }
9767 +
9768 + wp_send_json_success( $success_data );
9769 + }
9770 +
9771 + /**
9772 + * AJAX handler for deleting custom knowledge
9773 + *
9774 + * Endpoint: DELETE /v1/knowledge/files/{file_id}
9775 + */
9776 + public function ajax_delete_knowledge() {
9777 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9778 +
9779 + if ( ! current_user_can( 'manage_options' ) ) {
9780 + wp_send_json_error( [
9781 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9782 + ], 403 );
9783 + }
9784 +
9785 + $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : '';
9786 +
9787 + if ( empty( $file_id ) ) {
9788 + wp_send_json_error( [
9789 + 'message' => wpforo_phrase( 'File ID is required', false )
9790 + ], 400 );
9791 + }
9792 +
9793 + $response = $this->delete( '/knowledge/files/' . urlencode( $file_id ), [], 60 );
9794 +
9795 + if ( is_wp_error( $response ) ) {
9796 + wp_send_json_error( [
9797 + 'message' => $response->get_error_message()
9798 + ], 400 );
9799 + }
9800 +
9801 + $this->log_info( 'knowledge_deleted', [
9802 + 'file_id' => $file_id
9803 + ] );
9804 +
9805 + wp_send_json_success( [
9806 + 'message' => wpforo_phrase( 'Knowledge file deleted successfully.', false )
9807 + ] );
9808 + }
9809 +
9810 + /**
9811 + * AJAX handler for checking async job status
9812 + *
9813 + * Endpoint: GET /v1/knowledge/jobs/{file_id}
9814 + * Used by polling to check if async indexing is complete
9815 + */
9816 + public function ajax_get_job_status() {
9817 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9818 +
9819 + if ( ! current_user_can( 'manage_options' ) ) {
9820 + wp_send_json_error( [
9821 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9822 + ], 403 );
9823 + }
9824 +
9825 + $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : '';
9826 +
9827 + if ( empty( $file_id ) ) {
9828 + wp_send_json_error( [
9829 + 'message' => wpforo_phrase( 'File ID is required', false )
9830 + ], 400 );
9831 + }
9832 +
9833 + $response = $this->get( '/knowledge/jobs/' . urlencode( $file_id ) );
9834 +
9835 + if ( is_wp_error( $response ) ) {
9836 + wp_send_json_error( [
9837 + 'message' => $response->get_error_message()
9838 + ], 400 );
9839 + }
9840 +
9841 + // Log completion or failure (only once per file)
9842 + $status = isset( $response['status'] ) ? $response['status'] : '';
9843 + $logged_key = 'wpforo_knowledge_logged_' . $file_id;
9844 +
9845 + if ( in_array( $status, [ 'enabled', 'failed' ], true ) && ! get_transient( $logged_key ) ) {
9846 + $file_name = isset( $response['name'] ) ? $response['name'] : $file_id;
9847 + $credits_used = isset( $response['credits_used'] ) ? (int) $response['credits_used'] : 0;
9848 + $chunk_count = isset( $response['chunk_count'] ) ? (int) $response['chunk_count'] : 0;
9849 + $error_msg = isset( $response['error_message'] ) ? $response['error_message'] : '';
9850 +
9851 + if ( $status === 'enabled' ) {
9852 + WPF()->ai_logs->log( [
9853 + 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING,
9854 + 'credits_used' => $credits_used,
9855 + 'status' => AILogs::STATUS_SUCCESS,
9856 + 'request_summary' => 'File: ' . $file_name,
9857 + 'response_summary' => sprintf( 'Indexed %d chunks, used %d credits', $chunk_count, $credits_used ),
9858 + 'user_type' => 'admin',
9859 + ] );
9860 + } else {
9861 + WPF()->ai_logs->log( [
9862 + 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING,
9863 + 'credits_used' => 0,
9864 + 'status' => AILogs::STATUS_ERROR,
9865 + 'request_summary' => 'File: ' . $file_name,
9866 + 'error_message' => $error_msg ?: 'Indexing failed',
9867 + 'user_type' => 'admin',
9868 + ] );
9869 + }
9870 +
9871 + // Mark as logged (expires in 1 hour - enough to prevent duplicate logs)
9872 + set_transient( $logged_key, true, HOUR_IN_SECONDS );
9873 + }
9874 +
9875 + wp_send_json_success( $response );
9876 + }
9877 +
9878 + /**
9879 + * AJAX handler for saving knowledge settings (priorities + enabled state)
9880 + *
9881 + * Settings are stored per-board in WordPress options:
9882 + * - ai_knowledge_enabled (0/1)
9883 + * - ai_knowledge_priorities (array of priorities per feature)
9884 + *
9885 + * Priorities are arrays of content sources in order:
9886 + * - Position 0 = First priority (1.3x boost)
9887 + * - Position 1 = Second priority (1.15x boost)
9888 + * - Position 2 = Third priority (1.0x - no boost)
9889 + */
9890 + public function ajax_save_knowledge_priorities() {
9891 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9892 +
9893 + if ( ! current_user_can( 'manage_options' ) ) {
9894 + wp_send_json_error( [
9895 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9896 + ], 403 );
9897 + }
9898 +
9899 + // Get board ID
9900 + $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
9901 +
9902 + // Switch to correct board context
9903 + if ( $board_id > 0 ) {
9904 + WPF()->change_board( $board_id );
9905 + }
9906 +
9907 + // Get enabled state
9908 + $enabled = isset( $_POST['enabled'] ) ? (int) (bool) $_POST['enabled'] : 0;
9909 +
9910 + // Valid content sources
9911 + $valid_sources = [ 'custom_knowledge', 'forum', 'wordpress' ];
9912 +
9913 + // Sanitize priority arrays from POST data
9914 + $priorities = [
9915 + 'search' => $this->sanitize_priority_array(
9916 + isset( $_POST['search_priority'] ) ? $_POST['search_priority'] : [],
9917 + $valid_sources,
9918 + [ 'forum', 'wordpress', 'custom_knowledge' ]
9919 + ),
9920 + 'chat' => $this->sanitize_priority_array(
9921 + isset( $_POST['chat_priority'] ) ? $_POST['chat_priority'] : [],
9922 + $valid_sources,
9923 + [ 'custom_knowledge', 'forum', 'wordpress' ]
9924 + ),
9925 + 'bot_reply' => $this->sanitize_priority_array(
9926 + isset( $_POST['bot_reply_priority'] ) ? $_POST['bot_reply_priority'] : [],
9927 + $valid_sources,
9928 + [ 'forum', 'custom_knowledge', 'wordpress' ]
9929 + ),
9930 + ];
9931 +
9932 + // Save to WordPress options (board-specific via wpforo_update_option)
9933 + wpforo_update_option( 'ai_knowledge_enabled', $enabled );
9934 + wpforo_update_option( 'ai_knowledge_priorities', $priorities );
9935 +
9936 + $this->log_info( 'knowledge_settings_saved', [
9937 + 'board_id' => $board_id,
9938 + 'enabled' => $enabled,
9939 + 'priorities' => $priorities
9940 + ] );
9941 +
9942 + wp_send_json_success( [
9943 + 'message' => wpforo_phrase( 'Settings saved successfully.', false )
9944 + ] );
9945 + }
9946 +
9947 + /**
9948 + * Sanitize and validate priority array
9949 + *
9950 + * @param mixed $input Raw input (may be array or string)
9951 + * @param array $valid_sources Valid content source values
9952 + * @param array $default Default priority order
9953 + * @return array Sanitized array with exactly 3 valid sources
9954 + */
9955 + private function sanitize_priority_array( $input, $valid_sources, $default = null ) {
9956 + if ( $default === null ) {
9957 + $default = [ 'forum', 'wordpress', 'custom_knowledge' ];
9958 + }
9959 +
9960 + if ( ! is_array( $input ) ) {
9961 + return $default;
9962 + }
9963 +
9964 + $sanitized = [];
9965 + foreach ( $input as $source ) {
9966 + $source = sanitize_key( $source );
9967 + if ( in_array( $source, $valid_sources, true ) && ! in_array( $source, $sanitized, true ) ) {
9968 + $sanitized[] = $source;
9969 + }
9970 + }
9971 +
9972 + // Ensure we have exactly 3 unique sources
9973 + if ( count( $sanitized ) !== 3 ) {
9974 + return $default;
9975 + }
9976 +
9977 + return $sanitized;
9978 + }
9979 +
9980 + /**
9981 + * AJAX handler for getting knowledge settings for a board
9982 + */
9983 + public function ajax_get_knowledge_settings() {
9984 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9985 +
9986 + if ( ! current_user_can( 'manage_options' ) ) {
9987 + wp_send_json_error( [
9988 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9989 + ], 403 );
9990 + }
9991 +
9992 + // Get board ID
9993 + $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
9994 +
9995 + // Switch to correct board context
9996 + if ( $board_id > 0 ) {
9997 + WPF()->change_board( $board_id );
9998 + }
9999 +
10000 + // Get settings from WordPress options (board-specific)
10001 + $enabled = (int) wpforo_get_option( 'ai_knowledge_enabled', 0 );
10002 + $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] );
10003 +
10004 + // Apply defaults if not set
10005 + $default_priorities = [
10006 + 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ],
10007 + 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ],
10008 + 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ],
10009 + ];
10010 +
10011 + if ( empty( $priorities ) || ! is_array( $priorities ) ) {
10012 + $priorities = $default_priorities;
10013 + } else {
10014 + foreach ( $default_priorities as $feature => $default ) {
10015 + if ( ! isset( $priorities[ $feature ] ) || ! is_array( $priorities[ $feature ] ) ) {
10016 + $priorities[ $feature ] = $default;
10017 + }
10018 + }
10019 + }
10020 +
10021 + wp_send_json_success( [
10022 + 'board_id' => $board_id,
10023 + 'enabled' => $enabled,
10024 + 'priorities' => $priorities
10025 + ] );
10026 + }
10027 +
10028 + /**
10029 + * AJAX handler for getting knowledge files list
10030 + *
10031 + * Makes one API call to backend:
10032 + * - GET /v1/knowledge/files - List of indexed files
10033 + *
10034 + * Settings (enabled, priorities) are stored in WordPress per-board
10035 + * and retrieved separately via ajax_get_knowledge_settings.
10036 + *
10037 + * Returns empty data gracefully if backend is not available yet.
10038 + */
10039 + public function ajax_get_knowledge_files() {
10040 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
10041 +
10042 + if ( ! current_user_can( 'manage_options' ) ) {
10043 + wp_send_json_error( [
10044 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
10045 + ], 403 );
10046 + }
10047 +
10048 + // Get files list from backend - return empty if not ready
10049 + $files_response = $this->get( '/knowledge/files' );
10050 + if ( is_wp_error( $files_response ) ) {
10051 + // Backend not available - return empty state
10052 + wp_send_json_success( [
10053 + 'files' => [],
10054 + 'totals' => [
10055 + 'total_files' => 0,
10056 + 'total_chunks' => 0,
10057 + 'total_credits' => 0,
10058 + ]
10059 + ] );
10060 + return;
10061 + }
10062 +
10063 + // Normalize files data - map backend field names to UI field names
10064 + $files = [];
10065 + if ( isset( $files_response['files'] ) && is_array( $files_response['files'] ) ) {
10066 + foreach ( $files_response['files'] as $file ) {
10067 + $files[] = [
10068 + 'file_id' => isset( $file['file_id'] ) ? $file['file_id'] : '',
10069 + 'name' => isset( $file['name'] ) ? $file['name'] : '',
10070 + 'url' => isset( $file['source_url'] ) ? $file['source_url'] : '',
10071 + 'type' => isset( $file['file_type'] ) ? $file['file_type'] : 'text',
10072 + 'size_bytes' => isset( $file['file_size_bytes'] ) ? (int) $file['file_size_bytes'] : 0,
10073 + 'chunks' => isset( $file['chunk_count'] ) ? (int) $file['chunk_count'] : 0,
10074 + 'credits_used' => isset( $file['credits_used'] ) ? (int) $file['credits_used'] : 0,
10075 + 'status' => isset( $file['status'] ) ? $file['status'] : 'unknown',
10076 + 'enabled' => isset( $file['status'] ) && $file['status'] === 'enabled',
10077 + 'created_at' => isset( $file['created_at'] ) ? $file['created_at'] : '',
10078 + ];
10079 + }
10080 + }
10081 +
10082 + wp_send_json_success( [
10083 + 'files' => $files,
10084 + 'totals' => [
10085 + 'total_files' => isset( $files_response['total'] ) ? (int) $files_response['total'] : count( $files ),
10086 + 'total_chunks' => isset( $files_response['total_chunks'] ) ? (int) $files_response['total_chunks'] : 0,
10087 + 'total_credits' => isset( $files_response['total_credits_used'] ) ? (int) $files_response['total_credits_used'] : 0,
10088 + ]
10089 + ] );
10090 + }
10091 +
10092 + /**
10093 + * Check if custom knowledge is enabled for the current board
10094 + *
10095 + * @return bool True if enabled
10096 + */
10097 + public function is_custom_knowledge_enabled() {
10098 + // Must have Business+ plan
10099 + if ( ! $this->is_feature_available( 'custom_knowledge' ) ) {
10100 + return false;
10101 + }
10102 +
10103 + // Custom knowledge only works in cloud storage mode
10104 + // (vectors are stored in S3 Vectors, not local WordPress DB)
10105 + if ( WPF()->vector_storage->is_local_mode() ) {
10106 + return false;
10107 + }
10108 +
10109 + // Check board-specific setting
10110 + return (bool) wpforo_get_option( 'ai_knowledge_enabled', 0 );
10111 + }
10112 +
10113 + /**
10114 + * Get custom knowledge priorities for the current board
10115 + *
10116 + * @param string $feature Feature name: 'search', 'chat', or 'bot_reply'
10117 + * @return array Priority order array
10118 + */
10119 + public function get_knowledge_priorities( $feature = 'search' ) {
10120 + $defaults = [
10121 + 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ],
10122 + 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ],
10123 + 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ],
10124 + ];
10125 +
10126 + $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] );
10127 +
10128 + if ( isset( $priorities[ $feature ] ) && is_array( $priorities[ $feature ] ) ) {
10129 + return $priorities[ $feature ];
10130 + }
10131 +
10132 + return isset( $defaults[ $feature ] ) ? $defaults[ $feature ] : $defaults['search'];
9476 10133 }
9477 10134 }