service_client = new AI_Search_Service(); // Load quota manager require_once plugin_dir_path( __FILE__ ) . 'class-quota-manager.php'; $this->quota_manager = new AI_Search_Quota_Manager(); } /** * Display main settings page with tabs */ public function display_settings_page() { // Check if setup wizard should be shown if ( isset( $_GET['setup'] ) && $_GET['setup'] === 'wizard' ) { $wizard = new AI_Search_Setup_Wizard(); $wizard->display_wizard_page(); return; } $active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general'; // No page-title H1: it added nothing on top of the tabs and, since // 1.34.0, competed for attention with the celebration hero right // after a claim. echo '
'; $this->display_tab_navigation( $active_tab ); $this->display_tab_content( $active_tab ); echo '
'; } /** * Display tab navigation */ /** * Map legacy tab slugs (pre-1.29.0) to the consolidated tabs so old * links, bookmarks and admin-post redirects keep working. */ private function normalize_tab( $tab ) { $aliases = [ 'search-config' => 'search', 'quota' => 'general', 'embeddings' => 'indexing', 'term-embeddings' => 'indexing', 'custom-fields' => 'content', // Tools folded into Advanced in 1.38.0 — the two were both // "settings you rarely need to touch". Old `tools` and even // older `cache` links still resolve. 'cache' => 'advanced', 'tools' => 'advanced', ]; return isset( $aliases[ $tab ] ) ? $aliases[ $tab ] : $tab; } private function display_tab_navigation( $active_tab ) { $active_tab = $this->normalize_tab( $active_tab ); // Rename the Upgrade tab dynamically: paid customers see "Subscription" // (the tab is where they manage their existing plan), free users see // "Upgrade" (the tab is where they activate one). Same slug either // way so bookmarks and deep links keep working. $sub_service = new AI_Search_Service(); $sub_quota = $sub_service->get_quota_info(); $sub_plan_key = ( is_array( $sub_quota ) && isset( $sub_quota['plan']['key'] ) ) ? (string) $sub_quota['plan']['key'] : 'free'; $is_paid = ( 'free' !== $sub_plan_key ) && is_array( $sub_quota ) && isset( $sub_quota['subscription'] ); $upgrade_label = $is_paid ? __( 'Subscription', 'ai-search' ) : __( 'Upgrade', 'ai-search' ); $tabs = [ 'general' => __( 'General', 'ai-search' ), 'appearance' => __( 'Appearance', 'ai-search' ), 'search' => __( 'Search', 'ai-search' ), 'indexing' => __( 'Sync with AI', 'ai-search' ), 'test' => __( 'Test search', 'ai-search' ), 'content' => __( 'Content', 'ai-search' ), 'insights' => __( 'Insights', 'ai-search' ), 'advanced' => __( 'Advanced', 'ai-search' ), 'upgrade' => $upgrade_label, ]; // WooCommerce gets its own tab on stores (v1.32.0), sitting right // after Content where its fields used to live. Post-1.38.0 the // Content tab is at position 6 (General, Appearance, Search, // Indexing, Test search, Content), so Woo slots in after 6. if ( class_exists( 'WooCommerce' ) ) { $tabs = array_slice( $tabs, 0, 6, true ) + [ 'woocommerce' => __( 'WooCommerce', 'ai-search' ) ] + array_slice( $tabs, 6, null, true ); } echo ''; } private function display_tab_content( $active_tab ) { $active_tab = $this->normalize_tab( $active_tab ); $views = plugin_dir_path( __FILE__ ) . 'views/'; switch ( $active_tab ) { case 'search': include $views . 'settings-search-config.php'; break; case 'indexing': include $views . 'settings-embeddings.php'; include $views . 'settings-term-embeddings.php'; break; case 'content': include $views . 'settings-custom-fields.php'; break; case 'woocommerce': include $views . 'settings-woocommerce.php'; break; case 'insights': include $views . 'settings-insights.php'; break; case 'advanced': // 1.38.0: Tools (cache) merged into Advanced. Two panels // stacked — provider chooser on top, cache management // below. Both are "settings a merchant rarely touches". include $views . 'settings-advanced.php'; include $views . 'settings-cache.php'; break; case 'upgrade': include $views . 'settings-upgrade.php'; break; case 'appearance': include $views . 'settings-appearance.php'; break; case 'test': include $views . 'settings-test.php'; break; case 'general': default: include $views . 'settings-general.php'; include $views . 'settings-quota.php'; break; } } // (v1.42.0) `generate_selected_embeddings()` is gone. The Indexing tab // now drives the same batched AJAX handler the setup wizard uses // (`wp_ajax_ai_search_generate_batch`), which fixes the shared-host // timeouts that a synchronous 500-post loop caused. See // AI_Search_Setup_Wizard::ajax_generate_batch() for the current path. /** * Bulk-generate embeddings for taxonomy terms that don't have one yet. * * Mirrors `generate_selected_embeddings` for the term side (v1.26.0). */ public function generate_term_embeddings_bulk() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_generate_term_embeddings' ); if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); } $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : ''; $limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : 100; $limit = min( max( $limit, 1 ), 1000 ); // Build the taxonomy list — explicit taxonomy or all enabled. $disabled_taxonomies = (array) get_option( 'ai_search_disabled_taxonomies', [] ); if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) { if ( in_array( $taxonomy, $disabled_taxonomies, true ) ) { wp_die( __( 'That taxonomy is currently disabled in settings.', 'ai-search' ) ); } $taxonomies = [ $taxonomy ]; } else { $public = get_taxonomies( [ 'public' => true ] ); $taxonomies = array_values( array_diff( $public, $disabled_taxonomies, [ 'post_format' ] ) ); } if ( empty( $taxonomies ) ) { wp_die( __( 'No eligible taxonomies to process.', 'ai-search' ) ); } $terms = get_terms( [ 'taxonomy' => $taxonomies, 'hide_empty' => false, 'number' => $limit, 'meta_query' => [ [ 'key' => '_ai_search_term_embedding', 'compare' => 'NOT EXISTS', ], ], ] ); $processed = []; if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { $ai_search = AI_Search::get_instance(); foreach ( $terms as $term ) { if ( $ai_search->generate_term_embedding( $term->term_id, $term->taxonomy ) ) { $processed[] = $term->name . ' (' . $term->taxonomy . ')'; } } } set_transient( 'ai_search_processed_term_names', $processed, MINUTE_IN_SECONDS ); wp_redirect( admin_url( 'admin.php?page=ai-search&tab=term-embeddings&term_embeddings_generated=true' ) ); exit; } /** * Clear all embedding cache (transients and post meta). */ public function clear_embeddings_cache() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_clear_cache' ); $cache_type = isset( $_POST['cache_type'] ) ? sanitize_text_field( $_POST['cache_type'] ) : 'transients'; $cleared_count = 0; if ( $cache_type === 'transients' || $cache_type === 'all' ) { $cleared_count += $this->clear_embedding_transients(); } if ( $cache_type === 'post_meta' || $cache_type === 'all' ) { $cleared_count += $this->clear_post_meta_embeddings(); } $message = $cache_type === 'all' ? 'all_cache_cleared' : $cache_type . '_cleared'; wp_redirect( admin_url( 'admin.php?page=ai-search&cache_cleared=' . $message . '&count=' . $cleared_count . '&tab=cache' ) ); exit; } /** * Validate the service token via admin action. */ public function validate_service_token() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_validate_token' ); $service_token = get_option( 'ai_search_service_token', '' ); if ( empty( $service_token ) ) { wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=no_token&tab=general' ) ); exit; } $validation_result = $this->service_client->validate_token(); if ( $validation_result === false ) { wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=error&tab=general' ) ); } elseif ( isset( $validation_result['valid'] ) && $validation_result['valid'] === true ) { set_transient( 'ai_search_validation_data', $validation_result, 300 ); wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=success&tab=general' ) ); } else { set_transient( 'ai_search_validation_error', $validation_result, 300 ); wp_redirect( admin_url( 'admin.php?page=ai-search&token_validation=invalid&tab=general' ) ); } exit; } /** * Handle the "Reconnect with new token" admin action. * * Triggered from the General settings tab when a connection check * surfaced an "Invalid token" error. Calls the service's recover-token * endpoint, then immediately re-validates so the user sees the same * green "Connection successful" notice they'd get from a manual check. */ public function recover_service_token() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_recover_token' ); $recovered = $this->service_client->recover_token(); if ( ! $recovered ) { wp_redirect( admin_url( 'admin.php?page=ai-search&token_recovery=failed&tab=general' ) ); exit; } // Re-validate with the freshly issued token so the success notice // shows live quota numbers from the service, not just a generic // "token rotated" message. $validation_result = $this->service_client->validate_token(); if ( is_array( $validation_result ) && ! empty( $validation_result['valid'] ) ) { set_transient( 'ai_search_validation_data', $validation_result, 300 ); wp_redirect( admin_url( 'admin.php?page=ai-search&token_recovery=success&token_validation=success&tab=general' ) ); } else { // Token rotation worked but the follow-up validate didn't — // probably a transient network issue. Still report success on the // recovery itself; the user can click "Check Connection" again. wp_redirect( admin_url( 'admin.php?page=ai-search&token_recovery=success&tab=general' ) ); } exit; } /** * Save custom fields settings. */ public function save_custom_fields_settings() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_save_custom_fields' ); $custom_fields = isset( $_POST['custom_fields'] ) ? $_POST['custom_fields'] : []; $post_types = get_post_types( [ 'public' => true ] ); foreach ( $post_types as $post_type ) { if ( isset( $custom_fields[ $post_type ] ) && is_array( $custom_fields[ $post_type ] ) ) { $selected_fields = array_map( 'sanitize_text_field', $custom_fields[ $post_type ] ); update_option( 'ai_search_custom_fields_' . $post_type, $selected_fields ); } else { delete_option( 'ai_search_custom_fields_' . $post_type ); } } wp_redirect( admin_url( 'admin.php?page=ai-search&custom_fields_saved=true&tab=custom-fields' ) ); exit; } /** * Save WooCommerce fields settings. */ public function save_woocommerce_fields_settings() { if ( ! current_user_can( 'manage_options' ) || ! class_exists( 'WooCommerce' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_save_woocommerce_fields' ); $woocommerce_fields = isset( $_POST['woocommerce_fields'] ) ? $_POST['woocommerce_fields'] : []; if ( ! empty( $woocommerce_fields ) && is_array( $woocommerce_fields ) ) { $selected_fields = array_map( 'sanitize_text_field', $woocommerce_fields ); update_option( 'ai_search_woocommerce_fields', $selected_fields ); } else { delete_option( 'ai_search_woocommerce_fields' ); } wp_redirect( admin_url( 'admin.php?page=ai-search&woocommerce_fields_saved=true&tab=woocommerce' ) ); exit; } /** * Clear embedding transients from WordPress cache. */ private function clear_embedding_transients() { global $wpdb; $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_search_embedding_%', '_transient_timeout_ai_search_embedding_%' ) ); $result += $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_service_embedding_%', '_transient_timeout_ai_service_embedding_%' ) ); return $result; } /** * Clear post meta embeddings. */ private function clear_post_meta_embeddings() { global $wpdb; // Delete the vector AND the model/timestamp sidecars introduced in // 1.35.0. Only the vector row counts for the returned "cleared" // number so pre-1.35 sites don't suddenly appear to have deleted // "twice as much". $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding' ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s)", '_ai_search_embedding_model', '_ai_search_embedding_generated_at' ) ); return $result; } /** * Clear post meta embeddings filtered by post type and optionally taxonomy/term. */ public function clear_embeddings_cache_filtered() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_clear_cache_filtered' ); if ( ! isset( $_POST['cpt'] ) ) { wp_die( __( 'No post type selected', 'ai-search' ) ); } $cpt = sanitize_text_field( $_POST['cpt'] ); $taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( $_POST['taxonomy'] ) : ''; $term_id = isset( $_POST['term'] ) ? intval( $_POST['term'] ) : 0; // Build query args to get post IDs $query_args = [ 'numberposts' => -1, 'post_type' => $cpt, 'post_status' => 'any', 'fields' => 'ids', 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'EXISTS' ] ] ]; // Add taxonomy filter if specified if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) && $term_id > 0 ) { $query_args['tax_query'] = [ [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_id, ] ]; } $post_ids = get_posts( $query_args ); $cleared_count = 0; if ( ! empty( $post_ids ) ) { foreach ( $post_ids as $post_id ) { if ( delete_post_meta( $post_id, '_ai_search_embedding' ) ) { $cleared_count++; } // v1.35.0: keep the model + generated_at sidecars in sync // with the vector meta so a later "regenerate" doesn't // display stale info. delete_post_meta( $post_id, '_ai_search_embedding_model' ); delete_post_meta( $post_id, '_ai_search_embedding_generated_at' ); } } wp_redirect( admin_url( 'admin.php?page=ai-search&cache_cleared=post_meta_filtered_cleared&count=' . $cleared_count . '&tab=cache' ) ); exit; } /** * Get cache statistics. */ public function get_cache_statistics() { global $wpdb; $transients = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_search_embedding_%', '_transient_ai_service_embedding_%' ) ); $post_meta = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding' ) ); return [ 'transients' => intval( $transients ), 'post_meta' => intval( $post_meta ) ]; } /** * Get all custom fields used by a specific post type. */ public function get_custom_fields_for_post_type( $post_type ) { global $wpdb; $fields = []; // Get regular custom fields (non-underscore prefixed) $query = $wpdb->prepare( " SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE p.post_type = %s AND pm.meta_key NOT LIKE '\_%' AND pm.meta_key != 'ai_search_embedding' AND pm.meta_value != '' ORDER BY pm.meta_key ", $post_type ); $regular_fields = $wpdb->get_col( $query ); if ( $regular_fields ) { $fields = array_merge( $fields, $regular_fields ); } // If ACF is active, get ACF field groups for this post type if ( function_exists( 'acf_get_field_groups' ) ) { $field_groups = acf_get_field_groups( [ 'post_type' => $post_type ] ); foreach ( $field_groups as $field_group ) { $acf_fields = acf_get_fields( $field_group['key'] ); if ( $acf_fields ) { foreach ( $acf_fields as $field ) { // Only include fields that can be converted to text $searchable_types = [ 'text', 'textarea', 'number', 'email', 'url', 'password', 'wysiwyg', 'select', 'checkbox', 'radio', 'button_group', 'true_false', 'post_object', 'relationship', 'taxonomy', 'user', 'date_picker', 'date_time_picker', 'time_picker', 'color_picker' ]; if ( in_array( $field['type'], $searchable_types ) ) { $fields[] = $field['name']; } } } } } // Remove duplicates and sort $fields = array_unique( $fields ); sort( $fields ); return $fields; } /** * Get available WooCommerce fields. */ public function get_woocommerce_fields() { return [ 'content' => [ 'product_description' => 'Product Description', 'product_short_description' => 'Short Description', 'product_sku' => 'Product SKU', ], 'taxonomies' => [ 'product_categories' => 'Product Categories', 'product_tags' => 'Product Tags', ] ]; } /** * Get product attributes. */ public function get_product_attributes() { if ( ! class_exists( 'WooCommerce' ) ) { return []; } return wc_get_attribute_taxonomies(); } /** * Get default WooCommerce fields to include. */ public function get_default_woocommerce_fields() { // Single source of truth shared with the search engine, so the UI // can never advertise defaults the indexer ignores (v1.32.0). return AI_Search::default_woocommerce_fields(); } /** * Refresh quota data from AI Search Service. * * This method will be called when the user clicks the "Refresh Quota" button. * Forces a fresh API call and updates the cache. */ public function refresh_quota_data() { // Security checks if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_refresh_quota', 'ai_search_quota_nonce' ); // Force refresh from API $quota_data = $this->quota_manager->get_quota_data( true ); if ( $quota_data !== null ) { // Successfully fetched and cached wp_redirect( admin_url( 'admin.php?page=ai-search&tab=quota"a_refreshed=success' ) ); } else { // Failed to fetch quota data - store error in transient for display $error_message = $this->get_last_quota_error(); if ( $error_message ) { set_transient( 'ai_search_quota_error', $error_message, 60 ); } wp_redirect( admin_url( 'admin.php?page=ai-search&tab=quota"a_refreshed=error' ) ); } exit; } /** * Handle the "Activate" button on the General tab. Called via admin-post. * * @since 1.34.0 */ public function handle_claim_subscription() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'ai-search' ) ); } check_admin_referer( 'ai_search_claim_subscription' ); $claim_key = isset( $_POST['ai_search_claim_key'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_search_claim_key'] ) ) : ''; $service = new AI_Search_Service(); $result = $service->claim_subscription( $claim_key ); if ( is_wp_error( $result ) ) { // Persist the error briefly so the redirected page can render it. set_transient( 'ai_search_claim_error', [ 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), ], 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=upgrade&claim=error' ) ); exit; } // Fresh quota to reflect the new plan immediately in the Quota tab. delete_transient( 'ai_search_quota_data' ); // Two persisted signals about the upgrade: // // - `claim_success` is a short-lived transient so the "welcome" // banner renders exactly once on the redirected page. // - `upgrade_pending_reindex` is a real option that survives // reloads, because the reindex has to actually happen. It stays // true until the customer either dismisses it or (ideally) // finishes a bulk regeneration on the paid model. // v1.35.0: 24-hour TTL (was 60 seconds). The celebration is // consumed by the first render on the General tab, so a longer // window just means "if you got sidetracked after clicking // Activate, you still see it when you come back today". set_transient( 'ai_search_claim_success', [ 'plan_key' => isset( $result['plan']['key'] ) ? (string) $result['plan']['key'] : '', 'plan_label' => isset( $result['plan']['label'] ) ? (string) $result['plan']['label'] : '', 'model' => isset( $result['plan']['model'] ) ? (string) $result['plan']['model'] : '', ], DAY_IN_SECONDS ); update_option( 'ai_search_upgrade_pending_reindex', true ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=upgrade&claim=success' ) ); exit; } /** * Handle the "Dismiss" on the "regenerate embeddings" panel. Sets the * option to false so the panel goes away on the next reload. * * @since 1.34.0 */ public function handle_dismiss_reindex_notice() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'ai-search' ) ); } check_admin_referer( 'ai_search_dismiss_reindex_notice' ); update_option( 'ai_search_upgrade_pending_reindex', false ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general' ) ); exit; } /** * Handle the "Move to another site" button. Internally still called * release_slot because that is the service-side terminology (and the * transient / admin-post action names would be painful to rename * without breakage); the customer-facing label is the friendlier one. * * @since 1.34.0 */ /** * AJAX: run a search twice — once with the AI Search filter attached, * once with it detached — and return both result sets so the Test * search tab can show them side by side. * * The purpose is a demonstrable "AI Search is doing work" moment for * merchants who install the plugin, complete the wizard, and want to * feel the difference on their own content before deciding to keep * the plugin around. * * @since 1.38.0 */ public function ajax_test_query() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Unauthorized access', 'ai-search' ) ], 403 ); } check_ajax_referer( 'ai_search_test_query', 'nonce' ); $query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : ''; if ( '' === $query || strlen( $query ) > 200 ) { wp_send_json_error( [ 'message' => __( 'Query missing or too long.', 'ai-search' ) ], 400 ); } // Optional per-run threshold override (0.10 - 1.00). Applied via // the plugin's own ai_search_similarity_threshold filter so the // engine treats it like a merchant-configured value. $threshold_override = null; if ( isset( $_POST['threshold'] ) ) { $t = (float) $_POST['threshold']; if ( $t >= 0.10 && $t <= 1.00 ) { $threshold_override = $t; } } if ( null !== $threshold_override ) { $t = $threshold_override; add_filter( 'ai_search_similarity_threshold', function () use ( $t ) { return $t; }, 99 ); } // Reset the fallback flag so a stale one from a previous request // does not leak into the "AI worked" narrative. delete_transient( 'ai_search_used_fallback' ); // First query: WordPress default. Detach posts_results, run, reattach. $ai_search = AI_Search::get_instance(); remove_filter( 'posts_results', [ $ai_search, 'filter_search_results' ], 10, 2 ); $wp_query = new WP_Query( [ 's' => $query, 'posts_per_page' => 8, 'post_status' => 'publish', ] ); $wp_results = []; foreach ( $wp_query->posts as $p ) { $wp_results[] = [ 'id' => $p->ID, 'title' => wp_strip_all_tags( get_the_title( $p ) ), 'url' => get_permalink( $p ), 'type' => $p->post_type, ]; } wp_reset_postdata(); // Reattach and run again with the AI Search pipeline active. // ai_search_force_run tells filter_search_results to skip its // is_admin() short-circuit - otherwise the filter no-ops for // every request coming from wp-admin AJAX and $ai_results ends // up identical to $wp_results (v1.41.1 fix for the Test Search // tab that customers noticed was returning "always the same // results"). add_filter( 'posts_results', [ $ai_search, 'filter_search_results' ], 10, 2 ); $ai_query = new WP_Query( [ 's' => $query, 'posts_per_page' => 8, 'post_status' => 'publish', 'ai_search_force_run' => 1, ] ); $ai_results = []; foreach ( $ai_query->posts as $p ) { $ai_results[] = [ 'id' => $p->ID, 'title' => wp_strip_all_tags( get_the_title( $p ) ), 'url' => get_permalink( $p ), 'type' => $p->post_type, 'exact' => method_exists( 'AI_Search', 'is_exact_match' ) ? AI_Search::is_exact_match( $p->ID ) : false, 'lexical' => method_exists( 'AI_Search', 'is_lexical_match' ) ? AI_Search::is_lexical_match( $p->ID ) : false, ]; } wp_reset_postdata(); // Diff by ID so the front-end can highlight the new items. $wp_ids = array_column( $wp_results, 'id' ); $new_ids = []; foreach ( $ai_results as $r ) { if ( ! in_array( $r['id'], $wp_ids, true ) ) { $new_ids[] = $r['id']; } } wp_send_json_success( [ 'query' => $query, 'threshold' => $threshold_override, 'wp' => $wp_results, 'ai' => $ai_results, 'new_ids' => $new_ids, 'wp_count' => count( $wp_results ), 'ai_count' => count( $ai_results ), 'fallback' => (bool) get_transient( 'ai_search_used_fallback' ), ] ); } public function handle_release_slot() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'ai-search' ) ); } check_admin_referer( 'ai_search_release_slot' ); $service = new AI_Search_Service(); $result = $service->release_slot(); delete_transient( 'ai_search_quota_data' ); if ( is_wp_error( $result ) ) { set_transient( 'ai_search_claim_error', [ 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), ], 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=upgrade&release=error' ) ); exit; } // Now that this site is back on Free, the "regenerate embeddings" // reminder from the last claim is no longer relevant - the model // switched back to text-embedding-3-small, so the existing large- // model vectors are the wrong shape. That is a whole separate // problem for another day; we do not want to nag about it either. delete_option( 'ai_search_upgrade_pending_reindex' ); set_transient( 'ai_search_release_success', true, 60 ); wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=upgrade&release=success' ) ); exit; } /** * Get the last quota API error from error log or return generic message. * * @return string Error message */ private function get_last_quota_error() { // Try to get error from transient (we'll set this in the service class) $error = get_transient( 'ai_search_quota_api_error' ); if ( $error ) { delete_transient( 'ai_search_quota_api_error' ); return $error; } return __( 'Unable to connect to AI Search Service. Please check your connection and try again.', 'ai-search' ); } }