| @@ -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 | */ |
| @@ -484,9 +570,9 @@ | ||
| 484 | 570 | update_option( 'wpforo_ai_payment_providers', array_map( 'sanitize_text_field', $subscription['payment_providers'] ) ); |
| 485 | 571 | } |
| 486 | 572 | |
| 487 | 573 | // Store last sync time for debugging |
| 488 | - $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql' ) ); | |
| 574 | + $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql', true ) ); | |
| 489 | 575 | } |
| 490 | 576 | |
| 491 | 577 | /** |
| 492 | 578 | * Get cached subscription plan |
| @@ -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' ], |
| @@ -783,9 +870,9 @@ | ||
| 783 | 870 | // which would skip saving when credits_added is 0 (e.g., due to cap enforcement) |
| 784 | 871 | if ( ! empty( $response['success'] ) && isset( $response['credits_added'] ) ) { |
| 785 | 872 | update_option( 'wpforo_ai_bonus_credits_claimed', true ); |
| 786 | 873 | update_option( 'wpforo_ai_bonus_credits_amount', (int) $response['credits_added'] ); |
| 787 | - update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql' ) ); | |
| 874 | + update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql', true ) ); | |
| 788 | 875 | |
| 789 | 876 | $this->log_info( 'bonus_credits_granted', [ |
| 790 | 877 | 'credits_added' => $response['credits_added'], |
| 791 | 878 | ] ); |
| @@ -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 |
| @@ -937,9 +1041,11 @@ | ||
| 937 | 1041 | * |
| 938 | 1042 | * @return array|WP_Error Backend response or error object |
| 939 | 1043 | */ |
| 940 | 1044 | public function cancel_indexing() { |
| 941 | - return $this->post( '/rag/cancel', [], 30 ); | |
| 1045 | + // Note: /rag/cancel was replaced by /rag/cleanup-jobs in backend | |
| 1046 | + // Both set indexing_cancel_until flag, cleanup-jobs also finalizes stuck jobs | |
| 1047 | + return $this->post( '/rag/cleanup-jobs', [], 30 ); | |
| 942 | 1048 | } |
| 943 | 1049 | |
| 944 | 1050 | /** |
| 945 | 1051 | * Cleanup stuck cloud indexing jobs (backend). |
| @@ -1062,8 +1168,11 @@ | ||
| 1062 | 1168 | if ( delete_transient( 'wpforo_ai_rag_status_' . $board_id ) ) { |
| 1063 | 1169 | $summary['transients_deleted']++; |
| 1064 | 1170 | } |
| 1065 | 1171 | |
| 1172 | + // Clear indexing breakdown cache so UI shows fresh counts | |
| 1173 | + WPF()->vector_storage->clear_indexing_status_breakdown_cache(); | |
| 1174 | + | |
| 1066 | 1175 | // Tell the backend to drop any in-flight cloud image_worker items. |
| 1067 | 1176 | // Safe in local mode: backend simply sets indexing_cancel_until on |
| 1068 | 1177 | // the tenant record with no other side effects. Errors are |
| 1069 | 1178 | // non-fatal — WP-side cleanup has already succeeded. |
| @@ -2786,8 +2895,16 @@ | ||
| 2786 | 2895 | if ( $min_score_setting > 0 ) { |
| 2787 | 2896 | $data['min_score'] = $min_score_setting / 100; // Convert percentage to 0-1 |
| 2788 | 2897 | } |
| 2789 | 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 | + | |
| 2790 | 2907 | $response = $this->post( '/search/semantic', $data ); |
| 2791 | 2908 | |
| 2792 | 2909 | if ( is_wp_error( $response ) ) { |
| 2793 | 2910 | $this->log_error( 'semantic_search_failed', $response->get_error_message() ); |
| @@ -3479,8 +3596,11 @@ | ||
| 3479 | 3596 | |
| 3480 | 3597 | $wp_post = get_post( (int) $wp_post_id ); |
| 3481 | 3598 | if ( ! $wp_post || $wp_post->post_status !== 'publish' ) continue; |
| 3482 | 3599 | |
| 3600 | + // Skip password-protected posts unless user has entered the password | |
| 3601 | + if ( post_password_required( $wp_post ) ) continue; | |
| 3602 | + | |
| 3483 | 3603 | // Use real WP post_type (post, page, product, etc.) |
| 3484 | 3604 | $post_type_obj = get_post_type_object( $wp_post->post_type ); |
| 3485 | 3605 | $post_type_label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst( $wp_post->post_type ); |
| 3486 | 3606 | |
| @@ -3522,8 +3642,47 @@ | ||
| 3522 | 3642 | 'author_url' => '', |
| 3523 | 3643 | 'created' => $wp_post->post_date ? date( 'Y-m-d H:i', strtotime( $wp_post->post_date ) ) : '', |
| 3524 | 3644 | 'created_ago' => $wp_post->post_date ? human_time_diff( strtotime( $wp_post->post_date ) ) . ' ago' : '', |
| 3525 | 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 | + ]; | |
| 3526 | 3685 | } else { |
| 3527 | 3686 | // ── Forum result ── |
| 3528 | 3687 | $topic_id = wpfval( $result, 'topic_id' ) ?: wpfval( $result, 'metadata', 'thread_id' ); |
| 3529 | 3688 | |
| @@ -3716,8 +3875,21 @@ | ||
| 3716 | 3875 | |
| 3717 | 3876 | // Update total to reflect filtered results count |
| 3718 | 3877 | $filtered_total = count( $enriched_results ); |
| 3719 | 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 | + | |
| 3720 | 3892 | // Return enriched results with AI enhancement |
| 3721 | 3893 | wp_send_json_success( [ |
| 3722 | 3894 | 'results' => $enriched_results, |
| 3723 | 3895 | 'total' => $filtered_total, |
| @@ -4380,8 +4552,10 @@ | ||
| 4380 | 4552 | if ( $updated > 0 ) { |
| 4381 | 4553 | $this->log_info( 'topics_indexed_hash_updated', [ 'count' => $updated ] ); |
| 4382 | 4554 | // Clear topic cache to reflect indexed status |
| 4383 | 4555 | wpforo_clean_cache( 'topic' ); |
| 4556 | + // Clear indexing stats cache for fresh counts in UI | |
| 4557 | + WPF()->vector_storage->clear_indexing_stats_cache(); | |
| 4384 | 4558 | } |
| 4385 | 4559 | |
| 4386 | 4560 | return (int) $updated; |
| 4387 | 4561 | } |
| @@ -6925,8 +7099,15 @@ | ||
| 6925 | 7099 | 'message' => wpforo_phrase( 'Post not found', false ) |
| 6926 | 7100 | ], 404 ); |
| 6927 | 7101 | } |
| 6928 | 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 | + | |
| 6929 | 7110 | // Get the rendered HTML content using output buffering |
| 6930 | 7111 | // (wpforo_content echoes instead of returning) |
| 6931 | 7112 | ob_start(); |
| 6932 | 7113 | wpforo_content( $post ); |
| @@ -6956,10 +7137,11 @@ | ||
| 6956 | 7137 | 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ), |
| 6957 | 7138 | ] ); |
| 6958 | 7139 | } |
| 6959 | 7140 | // Return cached translation (no credits used) |
| 7141 | + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags | |
| 6960 | 7142 | wp_send_json_success( [ |
| 6961 | - 'translated_content' => wpfval( $cached_result, 'translated_content' ) ?: '', | |
| 7143 | + 'translated_content' => wpforo_kses( (string) wpfval( $cached_result, 'translated_content' ) ), | |
| 6962 | 7144 | 'source_language' => wpfval( $cached_result, 'source_language' ) ?: 'auto', |
| 6963 | 7145 | 'target_language' => $target_language, |
| 6964 | 7146 | 'credits_used' => 0, |
| 6965 | 7147 | 'cached' => true, |
| @@ -7015,10 +7197,11 @@ | ||
| 7015 | 7197 | ]; |
| 7016 | 7198 | $this->set_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key, $cache_data, 0, $post_id ); |
| 7017 | 7199 | |
| 7018 | 7200 | // Return translated content |
| 7201 | + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags | |
| 7019 | 7202 | wp_send_json_success( [ |
| 7020 | - 'translated_content' => wpfval( $result, 'translated_content' ) ?: '', | |
| 7203 | + 'translated_content' => wpforo_kses( (string) wpfval( $result, 'translated_content' ) ), | |
| 7021 | 7204 | 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto', |
| 7022 | 7205 | 'target_language' => $target_language, |
| 7023 | 7206 | 'credits_used' => $credits_used, |
| 7024 | 7207 | 'cached' => false, |
| @@ -7178,8 +7361,15 @@ | ||
| 7178 | 7361 | 'message' => wpforo_phrase( 'Topic not found', false ) |
| 7179 | 7362 | ], 404 ); |
| 7180 | 7363 | } |
| 7181 | 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 | + | |
| 7182 | 7372 | // Get summary style from settings or request |
| 7183 | 7373 | $style = sanitize_text_field( wpfval( $_POST, 'style' ) ); |
| 7184 | 7374 | if ( empty( $style ) ) { |
| 7185 | 7375 | $style = wpfval( WPF()->settings->ai, 'topic_summary_style' ) ?: 'detailed'; |
| @@ -7288,13 +7478,14 @@ | ||
| 7288 | 7478 | } |
| 7289 | 7479 | |
| 7290 | 7480 | // Return cached summary (no credits used) |
| 7291 | 7481 | // Process link markers to convert [[#POST_ID]] to clickable links |
| 7292 | - $cached_summary = wpfval( $cached_result, 'summary' ) ?: ''; | |
| 7482 | + $cached_summary = (string) wpfval( $cached_result, 'summary' ); | |
| 7293 | 7483 | $cached_summary = $this->replace_summary_link_markers( $cached_summary, $topicid ); |
| 7294 | 7484 | |
| 7485 | + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags | |
| 7295 | 7486 | wp_send_json_success( [ |
| 7296 | - 'summary' => $cached_summary, | |
| 7487 | + 'summary' => wpforo_kses( $cached_summary ), | |
| 7297 | 7488 | 'style' => wpfval( $cached_result, 'style' ) ?: $style, |
| 7298 | 7489 | 'topic_id' => $topicid, |
| 7299 | 7490 | 'reply_count' => $reply_count, |
| 7300 | 7491 | 'total_posts_count' => $total_posts_count, |
| @@ -7333,9 +7524,9 @@ | ||
| 7333 | 7524 | ], 500 ); |
| 7334 | 7525 | } |
| 7335 | 7526 | |
| 7336 | 7527 | // Get raw summary and store in cache (keep raw with link markers for re-processing) |
| 7337 | - $raw_summary = wpfval( $result, 'summary' ) ?: ''; | |
| 7528 | + $raw_summary = (string) wpfval( $result, 'summary' ); | |
| 7338 | 7529 | // Strip markdown code fence wrappers (```html ... ```) that LLMs sometimes add around HTML output |
| 7339 | 7530 | $raw_summary = preg_replace( '/^\s*```\w*\s*\n([\s\S]*?)\n\s*```\s*$/s', '$1', $raw_summary ); |
| 7340 | 7531 | $credits_used = wpfval( $result, 'credits_used' ) ?: 1; |
| 7341 | 7532 | |
| @@ -7366,10 +7557,11 @@ | ||
| 7366 | 7557 | // Process link markers to convert [[#POST_ID]] to clickable links |
| 7367 | 7558 | $processed_summary = $this->replace_summary_link_markers( $raw_summary, $topicid ); |
| 7368 | 7559 | |
| 7369 | 7560 | // Return summary with clickable links |
| 7561 | + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags | |
| 7370 | 7562 | wp_send_json_success( [ |
| 7371 | - 'summary' => $processed_summary, | |
| 7563 | + 'summary' => wpforo_kses( $processed_summary ), | |
| 7372 | 7564 | 'style' => wpfval( $result, 'style' ) ?: $style, |
| 7373 | 7565 | 'topic_id' => $topicid, |
| 7374 | 7566 | 'reply_count' => $reply_count, |
| 7375 | 7567 | 'total_posts_count' => $total_posts_count, |
| @@ -7620,8 +7812,17 @@ | ||
| 7620 | 7812 | if ( $accessible_forumids !== null ) { |
| 7621 | 7813 | $payload['accessible_forumids'] = $accessible_forumids; |
| 7622 | 7814 | } |
| 7623 | 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 | + | |
| 7624 | 7825 | // Make API request to suggestions endpoint |
| 7625 | 7826 | $response = $this->post( '/suggestions/suggest', $payload ); |
| 7626 | 7827 | |
| 7627 | 7828 | if ( is_wp_error( $response ) ) { |
| @@ -8766,8 +8967,16 @@ | ||
| 8766 | 8967 | 'knowledge_source' => $knowledge_source, |
| 8767 | 8968 | 'response_language' => $response_language, |
| 8768 | 8969 | ]; |
| 8769 | 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 | + | |
| 8770 | 8979 | // Make API request to /tasks/generate endpoint |
| 8771 | 8980 | $response = wp_remote_post( $this->api_base_url . '/tasks/generate', [ |
| 8772 | 8981 | 'timeout' => 60, |
| 8773 | 8982 | 'headers' => [ |
| @@ -9468,6 +9677,458 @@ | ||
| 9468 | 9677 | } |
| 9469 | 9678 | } |
| 9470 | 9679 | |
| 9471 | 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']; | |
| 9472 | 10133 | } |
| 9473 | 10134 | } |