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',
'cache' => 'tools',
];
return isset( $aliases[ $tab ] ) ? $aliases[ $tab ] : $tab;
}
private function display_tab_navigation( $active_tab ) {
$active_tab = $this->normalize_tab( $active_tab );
$tabs = [
'general' => __( 'General', 'ai-search' ),
'search' => __( 'Search', 'ai-search' ),
'indexing' => __( 'Indexing', 'ai-search' ),
'content' => __( 'Content', 'ai-search' ),
'insights' => __( 'Insights', 'ai-search' ),
'tools' => __( 'Tools', 'ai-search' ),
];
// WooCommerce gets its own tab on stores (v1.32.0), sitting right
// after Content where its fields used to live.
if ( class_exists( 'WooCommerce' ) ) {
$tabs = array_slice( $tabs, 0, 4, true )
+ [ 'woocommerce' => __( 'WooCommerce', 'ai-search' ) ]
+ array_slice( $tabs, 4, 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 'tools':
include $views . 'settings-cache.php';
break;
case 'general':
default:
include $views . 'settings-general.php';
include $views . 'settings-quota.php';
break;
}
}
/**
* Generate embedding for selected custom post type.
*/
public function generate_selected_embeddings() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( __( 'Unauthorized access', 'ai-search' ) );
}
check_admin_referer( 'ai_search_generate_embeddings' );
if ( ! isset( $_POST['cpt'] ) ) {
wp_die( __( 'No post type selected', 'ai-search' ) );
}
// Increase execution time for large batches
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( 300 ); // 5 minutes
}
$cpt = sanitize_text_field( $_POST['cpt'] );
$limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : 50;
$limit = min( max( $limit, 1 ), 1000 );
// Build query args
$query_args = [
'numberposts' => $limit,
'post_type' => $cpt,
'post_status' => 'publish',
'meta_query' => [
[
'key' => '_ai_search_embedding',
'compare' => 'NOT EXISTS'
]
]
];
// Add taxonomy filter if specified
$taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( $_POST['taxonomy'] ) : '';
$term_id = isset( $_POST['term'] ) ? intval( $_POST['term'] ) : 0;
if ( ! empty( $taxonomy ) && taxonomy_exists( $taxonomy ) && $term_id > 0 ) {
$query_args['tax_query'] = [
[
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $term_id,
]
];
}
$posts = get_posts( $query_args );
// Per-run report so the UI can say exactly what happened instead of
// "generated!" regardless of outcome (v1.27.0).
$report = [
'post_type' => $cpt,
'requested' => count( $posts ),
'ok' => [],
'failed' => [],
'stopped' => false,
];
$ai_search = AI_Search::get_instance();
foreach ( $posts as $post ) {
$result = $ai_search->generate_embedding( $post->ID );
if ( is_wp_error( $result ) ) {
$data = $result->get_error_data();
$report['failed'][] = [
'id' => $post->ID,
'title' => $post->post_title,
'code' => $result->get_error_code(),
'message' => $result->get_error_message(),
'status' => is_array( $data ) && isset( $data['status'] ) ? $data['status'] : null,
];
// Provider-level failures will repeat for every post — stop
// early instead of burning the whole batch on the same error.
if ( in_array( $result->get_error_code(), [ 'no_token', 'no_api_key', 'invalid_token', 'invalid_api_key', 'quota_exceeded', 'http_error', 'service_unavailable' ], true ) ) {
$report['stopped'] = true;
break;
}
continue;
}
$report['ok'][] = $post->post_title;
}
set_transient( 'ai_search_generation_report', $report, 5 * MINUTE_IN_SECONDS );
wp_redirect( admin_url( 'admin.php?page=ai-search&embeddings_generated=true&tab=embeddings' ) );
exit;
}
/**
* 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;
$result = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_ai_search_embedding'
)
);
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++;
}
}
}
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=general&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.
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'] : '',
], 60 );
update_option( 'ai_search_upgrade_pending_reindex', true );
wp_safe_redirect( admin_url( 'admin.php?page=ai-search&tab=general&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
*/
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=general&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=general&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' );
}
}