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 '';
foreach ( $tabs as $slug => $label ) {
echo '' . esc_html( $label ) . '';
}
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' );
}
}