Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/everest-forms/addons/Addons.php
+++ b/everest-forms/addons/Addons.php
@@ -14,6 +14,7 @@
use EverestFormsAddonsDiviBuilderDiviBuilder;
use EverestFormsAddonsBeaverBuilderBeaverBuilder;
use EverestFormsAddonsCleanTalkCleanTalk;
+use EverestFormsAddonsShieldSilentCaptchaShieldSilentCaptcha;
use EverestFormsAddonsWPBakeryBuilderWPBakeryBuilder;
use EverestFormsTraitsSingleton;
@@ -88,7 +89,8 @@
*/
public function not_addons_init() {
$addons = array(
- 'clean-talk' => CleanTalk::class,
+ 'clean-talk' => CleanTalk::class,
+ 'shield-silent-captcha' => ShieldSilentCaptcha::class,
);
foreach ( $addons as $key => $class_name ) {
--- a/everest-forms/addons/ShieldSilentCaptcha/ShieldSilentCaptcha.php
+++ b/everest-forms/addons/ShieldSilentCaptcha/ShieldSilentCaptcha.php
@@ -0,0 +1,521 @@
+<?php
+/**
+ * Shield silentCAPTCHA integration.
+ *
+ * @since 3.7.1
+ * @package EverestFormsAddonsShieldSilentCaptcha
+ */
+
+namespace EverestFormsAddonsShieldSilentCaptcha;
+
+use EverestFormsTraitsSingleton;
+
+/**
+ * Shield silentCAPTCHA integration.
+ *
+ * @since 3.7.1
+ */
+class ShieldSilentCaptcha {
+
+ use Singleton;
+
+ /**
+ * Shield global option key.
+ */
+ const SETTING_KEY = 'shield_silent_captcha';
+
+ /**
+ * Help URL.
+ */
+ const HELP_URL = 'https://clk.shldscrty.com/silentcaptchaintegrationhelp';
+
+ /**
+ * Cached Shield availability.
+ *
+ * @var bool|null
+ */
+ protected $shield_available = null;
+
+ /**
+ * Cached Shield threshold-zero status.
+ *
+ * @var bool|null
+ */
+ protected $shield_threshold_zero = null;
+
+ /**
+ * Constructor.
+ *
+ * @since 3.7.1
+ */
+ public function __construct() {
+ $this->setup();
+ }
+
+ /**
+ * Setup hooks.
+ *
+ * @since 3.7.1
+ */
+ public function setup() {
+ add_filter( 'everest_forms_recaptcha_integration_settings', array( $this, 'add_recaptcha_integration_settings' ) );
+ add_action( 'everest_forms_inline_security_settings', array( $this, 'add_inline_security_settings' ) );
+ add_action( 'everest_forms_admin_field_shield_silent_captcha_info', array( $this, 'render_global_info_field' ) );
+ add_filter( 'everest_forms_process_initial_errors', array( $this, 'filter_initial_errors' ), 10, 2 );
+ }
+
+ /**
+ * Add Shield provider to CAPTCHA integration settings.
+ *
+ * @since 3.7.1
+ *
+ * @param array $settings Settings array.
+ * @return array
+ */
+ public function add_recaptcha_integration_settings( $settings ) {
+ $is_available = $this->is_shield_available();
+ $toggle_field = $this->get_global_toggle_field( $is_available );
+ $info_field = array(
+ 'type' => 'shield_silent_captcha_info',
+ 'desc' => $this->get_global_toggle_desc( $is_available ),
+ );
+ $shield_accordion = array(
+ 'title' => esc_html__( 'Shield silentCAPTCHA', 'everest-forms' ),
+ 'icon' => plugins_url( 'assets/images/captcha/shield-security-logo.png', EVF_PLUGIN_FILE ),
+ 'is_enabled' => $this->is_global_shield_enabled(),
+ 'fields' => array( $toggle_field, $info_field ),
+ );
+
+ foreach ( $settings as &$setting ) {
+ if ( ! isset( $setting['type'] ) || 'accordion' !== $setting['type'] ) {
+ continue;
+ }
+
+ if ( ! isset( $setting['items'] ) || ! is_array( $setting['items'] ) ) {
+ continue;
+ }
+
+ $setting['items'][] = $shield_accordion;
+ break;
+ }
+
+ return $settings;
+ }
+
+ /**
+ * Render Shield global info paragraph below toggle control.
+ *
+ * @since 3.7.1
+ *
+ * @param array $field Field config.
+ */
+ public function render_global_info_field( $field ) {
+ if ( empty( $field['desc'] ) ) {
+ return;
+ }
+ ?>
+ <div class="everest-forms-global-settings">
+ <div class="everest-forms-global-settings--field">
+ <p class="description"><?php echo wp_kses_post( $field['desc'] ); ?></p>
+ </div>
+ </div>
+ <?php
+ }
+
+ /**
+ * Add Shield per-form toggle in builder security panel.
+ *
+ * @since 3.7.1
+ *
+ * @param object $builder Builder object.
+ */
+ public function add_inline_security_settings( $builder ) {
+ $is_available = $this->is_shield_available();
+ $toggle_args = array(
+ 'default' => '0',
+ 'tooltip' => esc_html__( 'Enable Shield silentCAPTCHA. Submission is checked only when this toggle and the global toggle are both enabled.', 'everest-forms' ),
+ );
+
+ echo '<div class="everest-forms-border-container">';
+ echo '<h4 class="everest-forms-border-container-title">' . esc_html__( 'Shield silentCAPTCHA', 'everest-forms' ) . '</h4>';
+
+ everest_forms_panel_field(
+ 'toggle',
+ 'settings',
+ self::SETTING_KEY,
+ $builder->form_data,
+ esc_html__( 'Enable Shield silentCAPTCHA bot protection', 'everest-forms' ),
+ $toggle_args
+ );
+
+ if ( ! $is_available ) {
+ printf(
+ '<p class="everest-forms-notice everest-forms-notice-info">%s</p>',
+ wp_kses_post( $this->get_unavailable_notice_html() )
+ );
+ }
+
+ echo '</div>';
+ }
+
+ /**
+ * Filter initial errors with Shield decision gate.
+ *
+ * @since 3.7.1
+ *
+ * @param array $errors Current error bag.
+ * @param array $form_data Current form data.
+ * @return array
+ */
+ public function filter_initial_errors( $errors, $form_data ) {
+ if ( isset( $_POST['__amp_form_verify'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ return $errors;
+ }
+
+ if ( ! is_array( $form_data ) || ! isset( $form_data['id'] ) ) {
+ return $errors;
+ }
+
+ $form_id = absint( $form_data['id'] );
+ if ( ! $form_id ) {
+ return $errors;
+ }
+
+ if ( isset( $errors[ $form_id ] ) && ! empty( $errors[ $form_id ] ) ) {
+ return $errors;
+ }
+
+ if ( ! $this->is_global_shield_enabled() ) {
+ return $errors;
+ }
+
+ if ( ! $this->is_form_shield_enabled( $form_data ) ) {
+ return $errors;
+ }
+
+ $request_ip = evf_get_ip_address();
+ $verdict = $this->get_shield_bot_verdict_for_ip( $request_ip );
+
+ if ( true !== $verdict ) {
+ return $errors;
+ }
+
+ $errors[ $form_id ]['header'] = apply_filters(
+ 'everest_forms_process_form_error_header',
+ __( 'Form has not been submitted, please see the errors below.', 'everest-forms' )
+ );
+
+ return $errors;
+ }
+
+ /**
+ * Detect whether Shield callable is available.
+ *
+ * @since 3.7.1
+ *
+ * @return bool
+ */
+ public function is_shield_available() {
+ if ( null !== $this->shield_available ) {
+ return $this->shield_available;
+ }
+
+ $this->shield_available = false;
+
+ foreach ( $this->get_shield_callables() as $callable ) {
+ if ( is_callable( $callable ) ) {
+ $this->shield_available = true;
+ break;
+ }
+ }
+
+ return $this->shield_available;
+ }
+
+ /**
+ * Resolve Shield verdict for a given request IP.
+ *
+ * @since 3.7.1
+ *
+ * @param string $request_ip Request IP.
+ * @return bool|null
+ */
+ public function get_shield_bot_verdict_for_ip( $request_ip ) {
+ foreach ( $this->get_shield_callables() as $callable ) {
+ if ( ! is_callable( $callable ) ) {
+ continue;
+ }
+
+ try {
+ $verdict = call_user_func( $callable, $request_ip );
+ } catch ( Throwable $e ) {
+ continue;
+ }
+
+ $normalized = $this->normalize_shield_verdict( $verdict );
+
+ if ( null !== $normalized ) {
+ return $normalized;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get Shield callable priority list.
+ *
+ * @since 3.7.1
+ *
+ * @return array
+ */
+ protected function get_shield_callables() {
+ return array(
+ '\FernleafSystems\Wordpress\Plugin\Shield\Functions\test_ip_is_bot',
+ 'shield_test_ip_is_bot',
+ );
+ }
+
+ /**
+ * Get Shield threshold callable priority list.
+ *
+ * @since 3.7.1
+ *
+ * @return array
+ */
+ protected function get_shield_threshold_callables() {
+ return array(
+ '\FernleafSystems\Wordpress\Plugin\Shield\Functions\get_silentcaptcha_bot_threshold',
+ 'shield_get_silentcaptcha_bot_threshold',
+ );
+ }
+
+ /**
+ * Build notice HTML with standard Shield help link.
+ *
+ * @since 3.7.1
+ *
+ * @param string $notice_text Notice text.
+ * @return string
+ */
+ protected function get_notice_with_help_link_html( $notice_text ) {
+ return sprintf(
+ /* translators: 1: notice text, 2: Shield help URL. */
+ __( '%1$s <a href="%2$s" target="_blank" rel="noopener noreferrer">Learn More</a>.', 'everest-forms' ),
+ esc_html( $notice_text ),
+ esc_url( self::HELP_URL )
+ );
+ }
+
+ /**
+ * Get lock attributes used when Shield is unavailable.
+ *
+ * @since 3.7.1
+ *
+ * @return array
+ */
+ protected function get_lock_attributes() {
+ return array(
+ 'disabled' => 'disabled',
+ 'data-shield-silent-captcha-lock' => '1',
+ 'data-shield-silent-captcha-available' => '0',
+ );
+ }
+
+ /**
+ * Get unavailable notice plain text.
+ *
+ * @since 3.7.1
+ *
+ * @return string
+ */
+ protected function get_unavailable_notice_text() {
+ return esc_html__( 'Shield Security bot detection is not currently detected, so this setting has no effect right now. Install and activate Shield Security to enable silentCAPTCHA bot checks.', 'everest-forms' );
+ }
+
+ /**
+ * Get unavailable notice HTML with help link.
+ *
+ * @since 3.7.1
+ *
+ * @return string
+ */
+ protected function get_unavailable_notice_html() {
+ return $this->get_notice_with_help_link_html( $this->get_unavailable_notice_text() );
+ }
+
+ /**
+ * Get threshold-zero notice plain text.
+ *
+ * @since 3.7.1
+ *
+ * @return string
+ */
+ protected function get_threshold_zero_notice_text() {
+ return esc_html__( 'Shield is active and running, but your Shield silentcaptcha bot threshold is set to zero.', 'everest-forms' );
+ }
+
+ /**
+ * Get threshold-zero notice HTML with help link.
+ *
+ * @since 3.7.1
+ *
+ * @return string
+ */
+ protected function get_threshold_zero_notice_html() {
+ return $this->get_notice_with_help_link_html( $this->get_threshold_zero_notice_text() );
+ }
+
+ /**
+ * Build Shield global toggle field.
+ *
+ * @since 3.7.1
+ *
+ * @param bool $is_available Availability state.
+ * @return array
+ */
+ protected function get_global_toggle_field( $is_available ) {
+ $field = array(
+ 'title' => esc_html__( 'Enable Shield silentCAPTCHA', 'everest-forms' ),
+ 'type' => 'toggle',
+ 'desc' => '',
+ 'id' => self::SETTING_KEY,
+ 'default' => 'no',
+ 'desc_tip' => false,
+ );
+
+ return $field;
+ }
+
+ /**
+ * Get global toggle description.
+ *
+ * @since 3.7.1
+ *
+ * @param bool $is_available Availability state.
+ * @return string
+ */
+ protected function get_global_toggle_desc( $is_available ) {
+ if ( ! $is_available ) {
+ return $this->get_unavailable_notice_html();
+ }
+
+ if ( $this->is_shield_silentcaptcha_threshold_zero() ) {
+ return $this->get_threshold_zero_notice_html();
+ }
+
+ return sprintf(
+ '%s<br/>%s %s',
+ /* translators: %s - Shield help URL. */
+ __( 'With Shield silentCAPTCHA Bot SPAM Protection, form submissions are blocked when requests are identified as coming from automated bots.', 'everest-forms' ),
+ __( "silentCAPTCHA is 100% invisible to your site visitors and you'll need to install the Shield Security plugin to use this feature.", 'everest-forms' ),
+ /* translators: %s: Shield help URL. */
+ sprintf( __( '<a href="%s" target="_blank" rel="noopener noreferrer">Learn More</a>.', 'everest-forms' ), esc_url( self::HELP_URL ) )
+ );
+ }
+
+ /**
+ * Determine whether Shield silentCAPTCHA threshold is explicitly set to zero.
+ *
+ * @since 3.7.1
+ *
+ * @return bool
+ */
+ protected function is_shield_silentcaptcha_threshold_zero() {
+ if ( null !== $this->shield_threshold_zero ) {
+ return $this->shield_threshold_zero;
+ }
+
+ $this->shield_threshold_zero = false;
+
+ if ( ! $this->is_shield_available() ) {
+ return $this->shield_threshold_zero;
+ }
+
+ foreach ( $this->get_shield_threshold_callables() as $callable ) {
+ if ( ! is_callable( $callable ) ) {
+ continue;
+ }
+
+ try {
+ $threshold = call_user_func( $callable );
+ } catch ( Throwable $e ) {
+ continue;
+ }
+
+ if ( ! is_numeric( $threshold ) ) {
+ continue;
+ }
+
+ $this->shield_threshold_zero = 0 === (int) $threshold;
+ break;
+ }
+
+ return $this->shield_threshold_zero;
+ }
+
+ /**
+ * Get admin control config for script locking.
+ *
+ * @since 3.7.1
+ *
+ * @return array
+ */
+ protected function get_admin_control_config() {
+ return array(
+ array(
+ 'shieldSilentCaptchaToggleSelector' => '#shield_silent_captcha',
+ 'shieldSilentCaptchaHiddenSelector' => '',
+ ),
+ array(
+ 'shieldSilentCaptchaToggleSelector' => '#everest-forms-panel-field-settings-shield_silent_captcha',
+ 'shieldSilentCaptchaHiddenSelector' => 'input[type="hidden"][name="settings[shield_silent_captcha]"]',
+ ),
+ );
+ }
+
+ /**
+ * Normalize Shield callable result into strict tri-state.
+ *
+ * @since 3.7.1
+ *
+ * @param mixed $verdict Raw callable result.
+ * @return bool|null
+ */
+ protected function normalize_shield_verdict( $verdict ) {
+ if ( true === $verdict ) {
+ return true;
+ }
+
+ if ( false === $verdict ) {
+ return false;
+ }
+
+ return null;
+ }
+
+ /**
+ * Check whether Shield is globally enabled.
+ *
+ * @since 3.7.1
+ *
+ * @return bool
+ */
+ protected function is_global_shield_enabled() {
+ return 'yes' === get_option( self::SETTING_KEY, 'no' );
+ }
+
+ /**
+ * Check whether Shield is enabled for the form.
+ *
+ * @since 3.7.1
+ *
+ * @param array $form_data Form data.
+ * @return bool
+ */
+ protected function is_form_shield_enabled( $form_data ) {
+ $setting_value = isset( $form_data['settings'][ self::SETTING_KEY ] ) ? $form_data['settings'][ self::SETTING_KEY ] : '0';
+
+ return '1' === (string) $setting_value;
+ }
+}
--- a/everest-forms/everest-forms.php
+++ b/everest-forms/everest-forms.php
@@ -3,7 +3,7 @@
* Plugin Name: Everest Forms
* Plugin URI: https://everestforms.net/
* Description: Easily create contact form, payment form, conversational form, calculator, multi-step form, registration form, quiz form, survey form etc.
- * Version: 3.4.8
+ * Version: 3.5.0
* Author: Everest Forms
* Author URI: https://everestforms.net/
* Text Domain: everest-forms
--- a/everest-forms/includes/Integrations/AI/class-evf-ai-ajax.php
+++ b/everest-forms/includes/Integrations/AI/class-evf-ai-ajax.php
@@ -0,0 +1,696 @@
+<?php
+/**
+ * EVF AI AJAX — handles all wp_ajax_ actions for AI form generation.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+class EVF_AI_Ajax {
+
+ public function __construct() {
+ add_action( 'wp_ajax_evf_ai_generate_form', array( $this, 'generate_form' ) );
+ add_action( 'wp_ajax_evf_ai_update_form', array( $this, 'update_form' ) );
+ add_action( 'wp_ajax_evf_ai_get_usage', array( $this, 'get_usage' ) );
+ add_action( 'wp_ajax_evf_ai_activate_form', array( $this, 'activate_form' ) );
+ add_action( 'wp_ajax_evf_ai_render_fields', array( $this, 'render_fields' ) );
+ }
+
+ /**
+ * Return the builder's freshly-rendered fields canvas + options panel HTML for
+ * a form, so the in-builder AI chat can refresh the builder in place (no full
+ * page reload) after an AI edit.
+ */
+ public function render_fields() {
+ check_ajax_referer( 'evf_ai_nonce', 'nonce' );
+
+ if ( ! current_user_can( 'manage_everest_forms' ) ) {
+ wp_send_json_error( array( 'message' => __( 'You do not have permission to use this feature.', 'everest-forms' ) ), 403 );
+ }
+
+ $form_id = absint( $_POST['form_id'] ?? 0 );
+ $post = $form_id ? get_post( $form_id ) : null;
+ if ( ! $post || 'everest_form' !== $post->post_type ) {
+ wp_send_json_error( array( 'message' => __( 'Form not found.', 'everest-forms' ) ) );
+ }
+
+ // EVF_Builder_Page reads the form from $_GET['form_id'] in its constructor.
+ $_GET['form_id'] = $form_id; // phpcs:ignore WordPress.Security.NonceVerification
+
+ if ( ! class_exists( 'EVF_Builder_Fields', false ) ) {
+ include_once dirname( EVF_PLUGIN_FILE ) . '/includes/admin/builder/class-evf-builder-page.php';
+ include_once dirname( EVF_PLUGIN_FILE ) . '/includes/admin/builder/class-evf-builder-fields.php';
+ }
+
+ if ( ! class_exists( 'EVF_Builder_Fields', false ) ) {
+ wp_send_json_error( array( 'message' => __( 'Builder is unavailable.', 'everest-forms' ) ) );
+ }
+
+ $builder = new EVF_Builder_Fields();
+
+ ob_start();
+ $builder->output_fields_preview();
+ $preview = ob_get_clean();
+
+ ob_start();
+ $builder->output_fields_options();
+ $options = ob_get_clean();
+
+ wp_send_json_success(
+ array(
+ 'preview' => $preview,
+ 'options' => $options,
+ 'title' => get_the_title( $form_id ),
+ )
+ );
+ }
+
+ /**
+ * Regenerate / refine the current draft AI form from a follow-up prompt.
+ * Rebuilds the SAME draft in place so the preview updates without creating a
+ * new form.
+ *
+ * NOTE: the Python gateway does not implement /ai/v1/update yet; this wires the
+ * call so it works once that endpoint ships. Until then it surfaces the
+ * gateway error to the user.
+ */
+ public function update_form() {
+ check_ajax_referer( 'evf_ai_nonce', 'nonce' );
+
+ if ( ! current_user_can( 'manage_everest_forms' ) ) {
+ wp_send_json_error( array( 'message' => __( 'You do not have permission to use this feature.', 'everest-forms' ) ), 403 );
+ }
+
+ $form_id = absint( $_POST['form_id'] ?? 0 );
+ $prompt = sanitize_textarea_field( wp_unslash( $_POST['prompt'] ?? '' ) );
+ $refine_prompt = sanitize_textarea_field( wp_unslash( $_POST['refine_prompt'] ?? '' ) );
+
+ if ( ! $form_id ) {
+ wp_send_json_error( array( 'message' => __( 'Invalid form ID.', 'everest-forms' ) ) );
+ }
+
+ if ( empty( $prompt ) ) {
+ wp_send_json_error( array( 'message' => __( 'Please describe what you want to change.', 'everest-forms' ) ) );
+ }
+
+ if ( EVF_AI_Registration::is_local_site() ) {
+ wp_send_json_error(
+ array(
+ 'message' => __( 'AI features are not available on local or staging sites.', 'everest-forms' ),
+ 'code' => 'not_registered',
+ )
+ );
+ }
+
+ if ( ! EVF_AI_Registration::is_registered() ) {
+ EVF_AI_Registration::register();
+ }
+
+ if ( ! EVF_AI_Registration::is_registered() ) {
+ wp_send_json_error(
+ array(
+ 'message' => __( 'AI features are not available on local or staging sites.', 'everest-forms' ),
+ 'code' => 'not_registered',
+ )
+ );
+ }
+
+ $ai_response = EVF_AI_API::update_form( $prompt, $form_id, $refine_prompt );
+
+ if ( is_wp_error( $ai_response ) ) {
+ wp_send_json_error(
+ array(
+ 'message' => $ai_response->get_error_message(),
+ 'code' => $ai_response->get_error_code(),
+ )
+ );
+ }
+
+ if ( self::ai_requests_recaptcha( $ai_response ) && ! EVF_AI_Form_Builder::is_recaptcha_configured() ) {
+ wp_send_json_error(
+ array(
+ 'message' => __( 'reCAPTCHA is not configured yet. Please add your site key under Everest Forms > Settings > reCAPTCHA, then try again.', 'everest-forms' ),
+ 'code' => 'recaptcha_not_configured',
+ )
+ );
+ }
+
+ // Snapshot form type before the update to detect type changes afterwards.
+ $before_post = get_post( $form_id );
+ $before_data = $before_post ? evf_decode( $before_post->post_content ) : array();
+ $before_settings = $before_data['settings'] ?? array();
+ $was_multipart = ! empty( $before_settings['enable_multi_part'] ) && evf_string_to_bool( $before_settings['enable_multi_part'] );
+
+ $result = EVF_AI_Form_Builder::update_form( $form_id, $ai_response );
+
+ if ( is_wp_error( $result ) ) {
+ wp_send_json_error( array( 'message' => $result->get_error_message() ) );
+ }
+
+ // Primary notice: check what was actually saved to the form.
+ // Fallback: if the AI didn't set the form type (e.g. returned "standard"),
+ // detect the user's intent from the prompt and surface the notice anyway.
+ $notice = self::get_pro_feature_notice( $form_id );
+ if ( '' === $notice && ! empty( $refine_prompt ) ) {
+ $prompt_check = self::check_pro_feature_from_prompt( $refine_prompt );
+ if ( $prompt_check ) {
+ $notice = $prompt_check->get_error_message();
+ }
+ }
+
+ // Read the saved form data once and reuse for all post-update checks.
+ $after_post = get_post( $form_id );
+ $after_data = $after_post ? evf_decode( $after_post->post_content ) : array();
+ $after_settings = $after_data['settings'] ?? array();
+
+ // A full page reload is needed whenever non-field settings changed — the fast
+ // canvas refresh (evfReloadBuilderFields) only updates the fields panel and
+ // doesn't refresh the Settings tab, so redirect/email/message changes would
+ // appear to the user as if they had no effect.
+ $needs_reload = false;
+
+ // Detect whether the refine prompt explicitly asks for a settings change
+ // that requires a full page reload to become visible in the builder.
+ // Pure field operations never contain these terms, eliminating false positives.
+ $prompt_lower = strtolower( $refine_prompt );
+
+ // Email keywords: keyword match alone is sufficient — if the user asks to
+ // set up / change email notifications, the Settings tab will have changed
+ // regardless of which specific value differed.
+ $email_keywords = array(
+ 'send mail',
+ 'send email',
+ 'send an email',
+ 'email notification',
+ 'email to admin',
+ 'mail to admin',
+ 'notify admin',
+ 'notification email',
+ 'admin notification',
+ 'from name',
+ 'send from',
+ 'sender name',
+ 'email from',
+ 'email subject',
+ 'mail subject',
+ );
+ foreach ( $email_keywords as $kw ) {
+ if ( false !== strpos( $prompt_lower, $kw ) ) {
+ $needs_reload = true;
+ break;
+ }
+ }
+
+ // Redirect keywords: run before/after comparison to confirm the redirect
+ // setting actually changed (avoids triggering reload when AI preserves it).
+ if ( ! $needs_reload ) {
+ $redirect_keywords = array(
+ 'redirect',
+ 'external url',
+ 'custom page',
+ 'thank you page',
+ 'confirmation page',
+ 'after submit',
+ 'after submission',
+ );
+ $prompt_has_redirect = false;
+ foreach ( $redirect_keywords as $kw ) {
+ if ( false !== strpos( $prompt_lower, $kw ) ) {
+ $prompt_has_redirect = true;
+ break;
+ }
+ }
+
+ if ( $prompt_has_redirect ) {
+ // redirect_to: '' and 'same' are equivalent.
+ $normalize_redirect = static function ( string $val ): string {
+ return '' === $val ? 'same' : $val;
+ };
+ if ( $normalize_redirect( $before_settings['redirect_to'] ?? '' ) !== $normalize_redirect( $after_settings['redirect_to'] ?? '' ) ) {
+ $needs_reload = true;
+ }
+ if ( ! $needs_reload && ( $before_settings['external_url'] ?? '' ) !== ( $after_settings['external_url'] ?? '' ) ) {
+ $needs_reload = true;
+ }
+ if ( ! $needs_reload && (int) ( $before_settings['custom_page'] ?? 0 ) !== (int) ( $after_settings['custom_page'] ?? 0 ) ) {
+ $needs_reload = true;
+ }
+ }
+ }
+
+ // Multi-part type change also requires a full reload (existing behaviour).
+ if ( ! $needs_reload && self::has_active_license() && class_exists( 'EverestForms_MultiPart' ) ) {
+ $now_multipart = ! empty( $after_settings['enable_multi_part'] ) && evf_string_to_bool( $after_settings['enable_multi_part'] );
+ if ( ! $was_multipart && $now_multipart ) {
+ $needs_reload = true;
+ }
+ }
+
+ wp_send_json_success(
+ array(
+ 'form_id' => $form_id,
+ 'form_title' => get_the_title( $form_id ),
+ 'fields' => EVF_AI_Form_Builder::get_field_summary( $form_id ),
+ 'required_addons' => $ai_response['required_addons'] ?? array(),
+ 'multi_part_steps' => self::get_multi_part_steps( $form_id ),
+ 'preview_html' => self::render_preview_html( $form_id ),
+ 'needs_reload' => $needs_reload,
+ 'notice' => $notice,
+ 'notice_url' => '' !== $notice ? self::get_notice_upgrade_url() : '',
+ )
+ );
+ }
+
+ /**
+ * Generate a form from a user prompt and return the new form's edit URL.
+ */
+ public function generate_form() {
+ check_ajax_referer( 'evf_ai_nonce', 'nonce' );
+
+ if ( ! current_user_can( 'manage_everest_forms' ) ) {
+ wp_send_json_error( array( 'message' => __( 'You do not have permission to use this feature.', 'everest-forms' ) ), 403 );
+ }
+
+ $prompt = sanitize_textarea_field( wp_unslash( $_POST['prompt'] ?? '' ) );
+
+ if ( empty( $prompt ) ) {
+ wp_send_json_error( array( 'message' => __( 'Please describe the form you want to create.', 'everest-forms' ) ) );
+ }
+
+ if ( strlen( $prompt ) < 5 ) {
+ wp_send_json_error( array( 'message' => __( 'Prompt is too short. Please provide more detail.', 'everest-forms' ) ) );
+ }
+
+ if ( EVF_AI_Registration::is_local_site() ) {
+ wp_send_json_error(
+ array(
+ 'message' => __( 'AI features are not available on local or staging sites.', 'everest-forms' ),
+ 'code' => 'not_registered',
+ )
+ );
+ }
+
+ // Auto-register on first use
+ if ( ! EVF_AI_Registration::is_registered() ) {
+ EVF_AI_Registration::register();
+ }
+
+ if ( ! EVF_AI_Registration::is_registered() ) {
+ wp_send_json_error(
+ array(
+ 'message' => __( 'AI features are not available on local or staging sites.', 'everest-forms' ),
+ 'code' => 'not_registered',
+ )
+ );
+ }
+
+ // Call gateway
+ $ai_response = EVF_AI_API::generate_form( $prompt );
+
+ // "Invalid token" means the stored token is stale (gateway restarted, URL changed, etc.)
+ // Auto-heal: clear credentials, re-register, and retry once — transparent to the user.
+ if ( is_wp_error( $ai_response ) && 'api_error' === $ai_response->get_error_code()
+ && false !== strpos( $ai_response->get_error_message(), 'Invalid token' ) ) {
+
+ EVF_AI_Registration::clear_credentials();
+ EVF_AI_Registration::register();
+ $ai_response = EVF_AI_API::generate_form( $prompt );
+ }
+
+ if ( is_wp_error( $ai_response ) ) {
+ $code = $ai_response->get_error_code();
+ wp_send_json_error(
+ array(
+ 'message' => $ai_response->get_error_message(),
+ 'code' => $code,
+ )
+ );
+ }
+
+ // Build and insert the EVF form
+ $form_id = EVF_AI_Form_Builder::create_form( $ai_response );
+
+ if ( is_wp_error( $form_id ) ) {
+ wp_send_json_error( array( 'message' => $form_id->get_error_message() ) );
+ }
+
+ $gen_notice = self::get_pro_feature_notice( $form_id );
+ wp_send_json_success(
+ array(
+ 'form_id' => $form_id,
+ 'form_title' => get_the_title( $form_id ),
+ 'edit_url' => admin_url( 'admin.php?page=evf-builder&tab=fields&form_id=' . $form_id ),
+ 'tier' => EVF_AI_Registration::get_tier(),
+ 'fields' => EVF_AI_Form_Builder::get_field_summary( $form_id ),
+ 'required_addons' => $ai_response['required_addons'] ?? array(),
+ 'multi_part_steps' => self::get_multi_part_steps( $form_id ),
+ 'preview_html' => self::render_preview_html( $form_id ),
+ 'notice' => $gen_notice,
+ 'notice_url' => '' !== $gen_notice ? self::get_notice_upgrade_url() : '',
+ )
+ );
+ }
+
+ /**
+ * Publish a draft AI form — called when user clicks "Use This Form".
+ */
+ public function activate_form() {
+ check_ajax_referer( 'evf_ai_nonce', 'nonce' );
+
+ if ( ! current_user_can( 'manage_everest_forms' ) ) {
+ wp_send_json_error( array(), 403 );
+ }
+
+ $form_id = absint( $_POST['form_id'] ?? 0 );
+ if ( ! $form_id ) {
+ wp_send_json_error( array( 'message' => __( 'Invalid form ID.', 'everest-forms' ) ) );
+ }
+
+ $ok = EVF_AI_Form_Builder::activate_form( $form_id );
+ if ( ! $ok ) {
+ wp_send_json_error( array( 'message' => __( 'Could not activate form.', 'everest-forms' ) ) );
+ }
+
+ wp_send_json_success(
+ array(
+ 'form_id' => $form_id,
+ 'edit_url' => admin_url( 'admin.php?page=evf-builder&tab=fields&form_id=' . $form_id ),
+ )
+ );
+ }
+
+ /**
+ * Return usage stats for display in the builder UI (requests used today, limit, etc.).
+ */
+ public function get_usage() {
+ check_ajax_referer( 'evf_ai_nonce', 'nonce' );
+
+ if ( ! current_user_can( 'manage_everest_forms' ) ) {
+ wp_send_json_error( array(), 403 );
+ }
+
+ $usage = EVF_AI_API::get_usage();
+
+ if ( is_wp_error( $usage ) ) {
+ wp_send_json_error( array( 'message' => $usage->get_error_message() ) );
+ }
+
+ wp_send_json_success( $usage );
+ }
+
+ /**
+ * Render the builder-canvas preview HTML for a form and return it as a string.
+ * Included inline in generate/update responses so React needs no second round trip.
+ *
+ * @param int $form_id
+ * @return string HTML string, or empty string on failure.
+ */
+ private static function render_preview_html( int $form_id ): string {
+ $post = get_post( $form_id );
+ if ( ! $post || 'everest_form' !== $post->post_type ) {
+ return '';
+ }
+
+ $form_content = evf_decode( $post->post_content );
+ if ( empty( $form_content ) || ! is_array( $form_content ) ) {
+ return '';
+ }
+
+ if ( ! class_exists( 'EVF_Builder_Fields', false ) ) {
+ include_once dirname( EVF_PLUGIN_FILE ) . '/includes/admin/builder/class-evf-builder-page.php';
+ include_once dirname( EVF_PLUGIN_FILE ) . '/includes/admin/builder/class-evf-builder-fields.php';
+ }
+
+ if ( ! class_exists( 'EVF_Builder_Fields', false ) ) {
+ return '';
+ }
+
+ $builder = new EVF_Builder_Fields();
+ $builder->form_data = $form_content;
+
+ return $builder->render_ai_preview( $form_content );
+ }
+
+ /**
+ * Reliable Pro license check usable inside AJAX handlers.
+ *
+ * evf_get_license_plan() has a shortcut for AJAX/REST/Cron that returns the
+ * cached 'evf_saved_license_plan' option, which defaults to 'personal' even on
+ * free sites. This helper bypasses that shortcut and checks the actual license
+ * key option + Pro plugin activation status instead.
+ *
+ * @return bool True only when EVF Pro is active AND a license key is stored.
+ */
+ private static function has_active_license(): bool {
+ if ( ! get_option( 'everest-forms-pro_license_key' ) ) {
+ return false;
+ }
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ include_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ return (bool) is_plugin_active( 'everest-forms-pro/everest-forms-pro.php' );
+ }
+
+ /**
+ * Return true when the EVF Pro plugin is active (regardless of license state).
+ * Used to distinguish "free user" (upgrade needed) from "Pro user with invalid
+ * license" (activate license) when showing feature-gate messages.
+ *
+ * @return bool
+ */
+ private static function is_pro_installed(): bool {
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ include_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ return (bool) is_plugin_active( 'everest-forms-pro/everest-forms-pro.php' );
+ }
+
+ /**
+ * Check whether the saved form uses a Pro feature (multipart / conversational)
+ * that is unavailable on the current installation, and return a human-readable
+ * notice string. Returns empty string when everything is available.
+ *
+ * Called after create_form() / update_form() so it reflects what was actually
+ * saved rather than what the AI said it would do.
+ *
+ * @param int $form_id
+ * @return string
+ */
+ /**
+ * Return the upgrade URL to include alongside a Pro feature notice.
+ * Returns the everestforms.net upgrade link when the user has no active license
+ * (free site or Pro installed but not activated). Returns empty string when the
+ * user has a valid license — they just need to install the addon, not upgrade.
+ *
+ * @return string
+ */
+ private static function get_notice_upgrade_url(): string {
+ if ( self::has_active_license() ) {
+ return ''; // Already Pro — installing the addon is all that's needed.
+ }
+ return 'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-chat&utm_campaign=ai-pro-feature&utm_content=Upgrade+to+Pro';
+ }
+
+ private static function get_pro_feature_notice( int $form_id ): string {
+ $post = get_post( $form_id );
+ if ( ! $post ) {
+ return '';
+ }
+ $data = evf_decode( $post->post_content );
+ $settings = $data['settings'] ?? array();
+
+ $has_license = self::has_active_license();
+ $pro_installed = self::is_pro_installed();
+
+ if ( EVF_AI_Form_Builder::$file_upload_limited ) {
+ if ( ! $has_license ) {
+ return __( 'Only one file upload field is included — the free plan allows one per form. Upgrade to EVF Pro to add more.', 'everest-forms' );
+ }
+ }
+
+ if ( ! empty( $settings['enable_multi_part'] ) && evf_string_to_bool( $settings['enable_multi_part'] ) ) {
+ if ( ! $has_license ) {
+ if ( $pro_installed ) {
+ return __( 'Your form is set up as Multi-Part. To activate it, please activate your Everest Forms Pro license under Everest Forms > Settings > License.', 'everest-forms' );
+ }
+ return __( 'Your form is set up as Multi-Part. This feature requires Everest Forms Pro — please upgrade to enable it.', 'everest-forms' );
+ }
+ if ( ! class_exists( 'EverestForms_MultiPart' ) ) {
+ return __( 'Your form is set up as Multi-Part, but the Multi-Part addon is not active. Please install and activate it from Everest Forms > Addons.', 'everest-forms' );
+ }
+ }
+
+ if ( ! empty( $settings['enable_conversational_forms'] ) && evf_string_to_bool( $settings['enable_conversational_forms'] ) ) {
+ if ( ! $has_license ) {
+ if ( $pro_installed ) {
+ return __( 'Your form is set up as Conversational. To activate it, please activate your Everest Forms Pro license under Everest Forms > Settings > License.', 'everest-forms' );
+ }
+ return __( 'Your form is set up as Conversational. This feature requires Everest Forms Pro — please upgrade to enable it.', 'everest-forms' );
+ }
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ include_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ if ( ! is_plugin_active( 'everest-forms-conversational-forms/everest-forms-conversational-forms.php' ) ) {
+ return __( 'Your form is set up as Conversational, but the Conversational Forms addon is not active. Please install and activate it from Everest Forms > Addons.', 'everest-forms' );
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Detect Pro feature requests directly from the user's prompt text, before
+ * calling the gateway. Matches natural-language variations of "multi-part form"
+ * and "conversational form". Returns a context-aware WP_Error when the feature
+ * is unavailable, null otherwise.
+ *
+ * @param string $prompt The user's refinement instruction.
+ * @return WP_Error|null
+ */
+ private static function check_pro_feature_from_prompt( string $prompt ): ?WP_Error {
+ $has_license = self::has_active_license();
+ $pro_installed = self::is_pro_installed();
+
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ include_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ if ( preg_match( '/multi[s-]?part|multi[s-]?step|wizard|step[s-]?by[s-]?step/i', $prompt ) ) {
+ if ( ! $has_license ) {
+ if ( $pro_installed ) {
+ $message = __( 'Multi-Part Form requires an active Everest Forms Pro license. Please activate your license under Everest Forms > Settings > License.', 'everest-forms' );
+ } else {
+ $message = __( 'Multi-Part Form is a Pro feature. Please upgrade to Everest Forms Pro to use this feature.', 'everest-forms' );
+ }
+ return new WP_Error( 'pro_feature_required', $message );
+ }
+ if ( ! class_exists( 'EverestForms_MultiPart' ) ) {
+ return new WP_Error( 'pro_feature_required', __( 'Multi-Part Forms addon is not active. Please install and activate it from Everest Forms > Addons.', 'everest-forms' ) );
+ }
+ }
+
+ if ( preg_match( '/conversational|one[s-]?question[s-]?at[s-]?a[s-]?time/i', $prompt ) ) {
+ if ( ! $has_license ) {
+ if ( $pro_installed ) {
+ $message = __( 'Conversational Form requires an active Everest Forms Pro license. Please activate your license under Everest Forms > Settings > License.', 'everest-forms' );
+ } else {
+ $message = __( 'Conversational Form is a Pro feature. Please upgrade to Everest Forms Pro to use this feature.', 'everest-forms' );
+ }
+ return new WP_Error( 'pro_feature_required', $message );
+ }
+ if ( ! is_plugin_active( 'everest-forms-conversational-forms/everest-forms-conversational-forms.php' ) ) {
+ return new WP_Error( 'pro_feature_required', __( 'Conversational Forms addon is not active. Please install and activate it from Everest Forms > Addons.', 'everest-forms' ) );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return true when the AI response explicitly requests reCAPTCHA/hCaptcha/Turnstile,
+ * either via the top-level enable_recaptcha flag or via a field with one of those types.
+ *
+ * @param array $ai_response Decoded AI gateway response.
+ * @return bool
+ */
+ private static function ai_requests_recaptcha( array $ai_response ): bool {
+ if ( ! empty( $ai_response['enable_recaptcha'] ) ) {
+ return true;
+ }
+ foreach ( $ai_response['fields'] ?? array() as $ai_field ) {
+ $t = strtolower( sanitize_key( $ai_field['type'] ?? '' ) );
+ $l = strtolower( $ai_field['label'] ?? '' );
+ if ( in_array( $t, array( 'recaptcha', 'hcaptcha', 'turnstile' ), true )
+ || false !== strpos( $l, 'recaptcha' )
+ || false !== strpos( $l, 'hcaptcha' )
+ || false !== strpos( $l, 'turnstile' ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check whether an AI response requests a Pro-only form type that is unavailable.
+ * A feature is only fully available when BOTH the addon is active AND a Pro license
+ * is active. Returns a context-aware WP_Error for every other combination:
+ *
+ * no license + no addon → upgrade to Pro
+ * no license + addon active → activate your license
+ * license + no addon → install/activate the addon
+ * license + addon active → allowed (returns null)
+ *
+ * @param array $ai_response Decoded AI gateway response.
+ * @return WP_Error|null
+ */
+ private static function check_pro_feature_availability( array $ai_response ): ?WP_Error {
+ $form_type = $ai_response['form_type'] ?? 'standard';
+ $required_addons = $ai_response['required_addons'] ?? array();
+ $has_license = self::has_active_license();
+
+ // Detect intent from either form_type or required_addons (defensive fallback in case
+ // the AI sets required_addons correctly but omits/misses the form_type key).
+ $is_multipart = 'multipart' === $form_type || in_array( 'multipart', $required_addons, true );
+ $is_conversational = 'conversational' === $form_type || in_array( 'conversational-forms', $required_addons, true );
+
+ $pro_installed = self::is_pro_installed();
+
+ if ( $is_multipart ) {
+ $addon_active = class_exists( 'EverestForms_MultiPart' );
+ if ( ! $addon_active || ! $has_license ) {
+ if ( $has_license ) {
+ $message = __( 'Multi-Part Forms addon is not active. Please install and activate it from Everest Forms > Addons.', 'everest-forms' );
+ } elseif ( $pro_installed ) {
+ $message = __( 'Multi-Part Form requires an active Everest Forms Pro license. Please activate your license under Everest Forms > Settings > License.', 'everest-forms' );
+ } else {
+ $message = __( 'Multi-Part Form is a Pro feature. Please upgrade to Everest Forms Pro to use this feature.', 'everest-forms' );
+ }
+ return new WP_Error( 'pro_feature_required', $message );
+ }
+ }
+
+ if ( $is_conversational ) {
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ include_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ $addon_active = is_plugin_active( 'everest-forms-conversational-forms/everest-forms-conversational-forms.php' );
+ if ( ! $addon_active || ! $has_license ) {
+ if ( $has_license ) {
+ $message = __( 'Conversational Forms addon is not active. Please install and activate it from Everest Forms > Addons.', 'everest-forms' );
+ } elseif ( $pro_installed ) {
+ $message = __( 'Conversational Form requires an active Everest Forms Pro license. Please activate your license under Everest Forms > Settings > License.', 'everest-forms' );
+ } else {
+ $message = __( 'Conversational Form is a Pro feature. Please upgrade to Everest Forms Pro to use this feature.', 'everest-forms' );
+ }
+ return new WP_Error( 'pro_feature_required', $message );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return ordered array of multi-part step names for a given form, or empty
+ * array if the form is not a multi-part form.
+ *
+ * @param int $form_id
+ * @return string[]
+ */
+ private static function get_multi_part_steps( int $form_id ): array {
+ $post = get_post( $form_id );
+ if ( ! $post ) {
+ return array();
+ }
+ $data = evf_decode( $post->post_content );
+ if ( empty( $data['settings']['enable_multi_part'] ) || ! evf_string_to_bool( $data['settings']['enable_multi_part'] ) ) {
+ return array();
+ }
+ if ( empty( $data['multi_part'] ) ) {
+ return array();
+ }
+ $steps = array();
+ foreach ( array_values( $data['multi_part'] ) as $part ) {
+ $steps[] = sanitize_text_field( $part['name'] ?? '' );
+ }
+ return $steps;
+ }
+}
--- a/everest-forms/includes/Integrations/AI/class-evf-ai-api.php
+++ b/everest-forms/includes/Integrations/AI/class-evf-ai-api.php
@@ -0,0 +1,324 @@
+<?php
+/**
+ * EVF AI API — HTTP client for the ThemeGrill AI Cloud gateway.
+ *
+ * Gateway URL is read from:
+ * 1. TG_AI_GATEWAY_URL constant (wp-config.php) — local dev override
+ * 2. 'evf_ai_gateway_url' option — settable from admin (future)
+ * 3. Hardcoded production URL as final fallback
+ *
+ * License pattern (follows WPForms): license key is sent inline with every
+ * generate request. The gateway verifies with wpeverest.com and caches for
+ * 1 week. No separate "activate" step needed.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+class EVF_AI_API {
+
+ const PRODUCTION_URL = 'https://ai.themegrill.com';
+ const PRODUCT = 'everest-forms';
+ const TIMEOUT = 90;
+
+ /**
+ * Generate a form from a plain-text prompt.
+ * Sends the EVF Pro license key inline — gateway verifies + caches (1 week).
+ *
+ * @param string $prompt
+ * @return array|WP_Error Decoded AI response on success.
+ */
+ public static function generate_form( string $prompt ) {
+ $token = EVF_AI_Registration::get_site_token();
+ if ( ! $token ) {
+ return new WP_Error( 'not_registered', __( 'AI features are not yet active on this site.', 'everest-forms' ) );
+ }
+
+ // Send license key if EVF Pro is active — gateway verifies inline (WPForms pattern).
+ // If no license key, gateway treats site as free tier.
+ $license_key = self::get_license_key();
+
+ $response = self::request(
+ 'POST',
+ '/ai/v1/generate',
+ array(
+ 'prompt' => $prompt,
+ 'license_key' => $license_key,
+ 'available_fields' => implode( ',', evf()->form_fields->get_form_field_types() ),
+ ),
+ $token
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ if ( empty( $response['success'] ) || empty( $response['form'] ) ) {
+ return new WP_Error( 'bad_response', __( 'Unexpected response from AI service.', 'everest-forms' ) );
+ }
+
+ return $response['form'];
+ }
+
+ /**
+ * Regenerate / refine an existing AI form from a follow-up prompt.
+ *
+ * NOTE: the gateway does not implement /ai/v1/update yet — this wires the call
+ * so it works the moment the Python endpoint ships. Until then it returns the
+ * gateway's error (surfaced to the user).
+ *
+ * @param string $prompt Refinement / follow-up prompt (or the original to regenerate).
+ * @param int $form_id The draft form being refined.
+ * @return array|WP_Error Decoded AI form schema on success.
+ */
+ public static function update_form( string $prompt, int $form_id = 0, string $refine_prompt = '' ) {
+ $token = EVF_AI_Registration::get_site_token();
+ if ( ! $token ) {
+ return new WP_Error( 'not_registered', __( 'AI features are not yet active on this site.', 'everest-forms' ) );
+ }
+
+ $body = array(
+ 'prompt' => $prompt,
+ 'refine_prompt' => $refine_prompt,
+ 'form_id' => $form_id,
+ 'license_key' => self::get_license_key(),
+ 'current_form' => self::get_current_form_context( $form_id ),
+ );
+
+ $response = self::request( 'POST', '/ai/v1/update', $body, $token );
+
+ // Auto-heal stale token — same pattern as generate_form
+ if ( is_wp_error( $response ) && 'api_error' === $response->get_error_code()
+ && false !== strpos( $response->get_error_message(), 'Invalid token' ) ) {
+
+ EVF_AI_Registration::clear_credentials();
+ EVF_AI_Registration::register();
+ $token = EVF_AI_Registration::get_site_token();
+ $response = self::request( 'POST', '/ai/v1/update', $body, $token );
+ }
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ if ( empty( $response['success'] ) || empty( $response['form'] ) ) {
+ return new WP_Error( 'bad_response', __( 'Unexpected response from AI service.', 'everest-forms' ) );
+ }
+
+ return $response['form'];
+ }
+
+ /**
+ * Extract a lightweight form context for the AI.
+ * Includes type, label, and any non-default field settings so that subsequent
+ * AI requests preserve changes made by earlier ones (e.g. label_hide, required).
+ *
+ * @param int $form_id
+ * @return array { form_title, fields: [ { type, label, ...settings } ] }
+ */
+ private static function get_current_form_context( int $form_id ): array {
+ if ( ! $form_id ) {
+ return [];
+ }
+
+ $post = get_post( $form_id );
+ if ( ! $post || 'everest_form' !== $post->post_type ) {
+ return [];
+ }
+
+ $data = evf_decode( $post->post_content );
+ $summary = [];
+
+ foreach ( ( $data['form_fields'] ?? [] ) as $field ) {
+ $type = $field['type'] ?? '';
+ if ( in_array( $type, [ 'hidden', 'html', 'divider' ], true ) ) {
+ continue;
+ }
+
+ $entry = [
+ 'type' => $type,
+ 'label' => $field['label'] ?? '',
+ ];
+
+ // Include non-default field settings so the AI can preserve them on
+ // subsequent requests without the user having to repeat their instructions.
+ if ( ! empty( $field['label_hide'] ) && '1' === $field['label_hide'] ) {
+ $entry['label_hide'] = true;
+ }
+ if ( ! empty( $field['required'] ) && '1' === $field['required'] ) {
+ $entry['required'] = true;
+ }
+ if ( ! empty( $field['description'] ) ) {
+ $entry['description'] = $field['description'];
+ }
+ if ( ! empty( $field['placeholder'] ) ) {
+ $entry['placeholder'] = $field['placeholder'];
+ }
+ if ( ! empty( $field['sublabel_hide'] ) && '1' === $field['sublabel_hide'] ) {
+ $entry['sublabel_hide'] = true;
+ }
+ if ( ! empty( $field['css'] ) ) {
+ $entry['css'] = $field['css'];
+ }
+
+ $summary[] = $entry;
+ }
+
+ // Detect form type so the gateway can preserve it during refine/regenerate
+ $form_type = 'standard';
+ if ( ! empty( $data['settings']['enable_multi_part'] ) && '1' === $data['settings']['enable_multi_part'] ) {
+ $form_type = 'multipart';
+ } elseif ( ! empty( $data['settings']['enable_conversational_forms'] ) && '1' === $data['settings']['enable_conversational_forms'] ) {
+ $form_type = 'conversational';
+ }
+
+ // Include multipart step titles so AI can preserve/extend them
+ $multipart_steps = [];
+ if ( 'multipart' === $form_type ) {
+ foreach ( ( $data['multi_part'] ?? [] ) as $part ) {
+ $multipart_steps[] = [
+ 'title' => $part['name'] ?? '',
+ 'field_count' => count( $part['fields'] ?? [] ),
+ ];
+ }
+ }
+
+ $email_conns = $data['settings']['email'] ?? [];
+ $conn1 = $email_conns['connection_1'] ?? [];
+
+ return [
+ 'form_title' => $post->post_title,
+ 'form_type' => $form_type,
+ 'multipart_steps' => $multipart_steps,
+ 'fields' => $summary,
+ // Settings context so the gateway preserves them on refine
+ 'redirect_to' => $data['settings']['redirect_to'] ?? 'same',
+ 'redirect_custom_page_id' => absint( $data['settings']['custom_page'] ?? 0 ),
+ 'redirect_external_url' => $data['settings']['external_url'] ?? '',
+ 'notification' => [
+ 'from_name' => $conn1['evf_from_name'] ?? '',
+ 'reply_to' => $conn1['evf_reply_to'] ?? 'auto',
+ 'message' => $conn1['evf_email_message'] ?? '{all_fields}',
+ 'subject' => $conn1['evf_email_subject'] ?? '',
+ ],
+ 'user_confirmation' => ! empty( $email_conns['connection_2'] ) ? [
+ 'from_name' => $email_conns['connection_2']['evf_from_name'] ?? '',
+ 'reply_to' => $email_conns['connection_2']['evf_reply_to'] ?? 'auto',
+ ] : [],
+ ];
+ }
+
+ /**
+ * Register this site with the ThemeGrill AI Cloud gateway (free tier).
+ * Called once on plugin activation — silent, no admin action required.
+ *
+ * @param string $verify_token One-time ownership token; gateway calls back to confirm.
+ * @return array|WP_Error { site_token, tier, product }
+ */
+ public static function register_site( string $verify_token = '' ) {
+ $payload = array(
+ 'domain' => self::get_domain(),
+ 'admin_email' => get_bloginfo( 'admin_email' ),
+ 'wp_version' => get_bloginfo( 'version' ),
+ 'product' => self::PRODUCT,
+ );
+
+ if ( $verify_token ) {
+ $payload['verify_token'] = $verify_token;
+ }
+
+ return self::request( 'POST', '/ai/v1/register', $payload );
+ }
+
+ /**
+ * Get current usage stats for display in the builder UI.
+ *
+ * @return array|WP_Error
+ */
+ public static function get_usage() {
+ $token = EVF_AI_Registration::get_site_token();
+ if ( ! $token ) {
+ return new WP_Error( 'not_registered', '' );
+ }
+ return self::request( 'GET', '/ai/v1/usage', array(), $token );
+ }
+
+ // ── Core HTTP request ─────────────────────────────────────────────────────
+
+ private static function request( string $method, string $path, array $body = array(), string $token = '' ) {
+ $url = rtrim( self::gateway_url(), '/' ) . $path;
+ $headers = array( 'Content-Type' => 'application/json' );
+
+ if ( $token ) {
+ $headers['X-TG-Token'] = $token;
+ }
+
+ $args = array(
+ 'method' => strtoupper( $method ),
+ 'headers' => $headers,
+ 'timeout' => self::TIMEOUT,
+ );
+
+ if ( ! empty( $body ) && 'GET' !== strtoupper( $method ) ) {
+ $args['body'] = wp_json_encode( $body );
+ }
+
+ $wp_response = wp_remote_request( $url, $args );
+
+ if ( is_wp_error( $wp_response ) ) {
+ return new WP_Error(
+ 'request_failed',
+ sprintf( __( 'Could not reach AI service: %s', 'everest-forms' ), $wp_response->get_error_message() )
+ );
+ }
+
+ $status = wp_remote_retrieve_response_code( $wp_response );
+ $body = json_decode( wp_remote_retrieve_body( $wp_response ), true );
+
+ if ( 429 === $status ) {
+ $msg = is_array( $body ) && isset( $body['detail']['message'] )
+ ? $body['detail']['message']
+ : __( 'Request limit reached. Please try again later.', 'everest-forms' );
+ $code = is_array( $body ) && isset( $body['detail']['error'] )
+ ? $body['detail']['error']
+ : 'rate_limited';
+ return new WP_Error( $code, $msg );
+ }
+
+ if ( $status < 200 || $status >= 300 ) {
+ $detail = is_array( $body ) ? ( $body['detail'] ?? $body['message'] ?? '' ) : '';
+ // FastAPI 400s send detail as an object: {"error": "...", "message": "..."}.
+ $msg = is_array( $detail ) ? ( $detail['message'] ?? $detail['error'] ?? '' ) : $detail;
+ $code = ( is_array( $detail ) && ! empty( $detail['error'] ) ) ? $detail['error'] : 'api_error';
+ return new WP_Error(
+ $code,
+ $msg ?: sprintf( __( 'AI service returned an error (%d).', 'everest-forms' ), $status )
+ );
+ }
+
+ return $body;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ public static function gateway_url(): string {
+ if ( defined( 'TG_AI_GATEWAY_URL' ) ) {
+ return TG_AI_GATEWAY_URL;
+ }
+ return get_option( 'evf_ai_gateway_url', self::PRODUCTION_URL );
+ }
+
+ /**
+ * Get EVF Pro license key if the license is active — empty string otherwise.
+ * Gateway treats an empty key as free tier.
+ */
+ private static function get_license_key(): string {
+ if ( ! function_exists( 'evf_get_license_plan' ) || ! evf_get_license_plan() ) {
+ return '';
+ }
+ return (string) get_option( 'everest-forms-pro_license_key', '' );
+ }
+
+ private static function get_domain(): string {
+ return preg_replace( '(^https?://)', '', rtrim( home_url(), '/' ) );
+ }
+}
--- a/everest-forms/includes/Integrations/AI/class-evf-ai-form-builder.php
+++ b/everest-forms/includes/Integrations/AI/class-evf-ai-form-builder.php
@@ -0,0 +1,961 @@
+<?php
+/**
+ * EVF AI Form Builder — transforms gateway AI response into full EVF form structure
+ * and inserts it as a WordPress post.
+ *
+ * Gateway returns a clean intermediate format (type, label, options, etc.).
+ * This class handles ALL EVF-specific boilerplate so the gateway stays simple.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+class EVF_AI_Form_Builder {
+
+ /** Pro-only field types — visible in builder but locked for free users. */
+ public static $pro_fields = [
+ 'password', 'color', 'range-slider', 'signature', 'repeater-fields',
+ 'lookup', 'progress',
+ 'payment-single', 'payment-checkbox', 'payment-multiple',
+ 'payment-quantity', 'payment-subtotal', 'payment-total',
+ 'payment-coupon', 'credit-card', 'payment-square',
+ 'payment-authorize-net', 'payment-subscription-plan',
+ 'payment-gateway-selector',
+ ];
+
+ /** Set to true when a file-upload field was dropped because the free-tier limit (1) was reached. */
+ public static $file_upload_limited = false;
+
+ /**
+ * Create a new EVF form from the AI gateway response.
+ * Saved as DRAFT — user must click "Use This Form" to publish.
+ *
+ * @param array $ai_response Decoded JSON from /evf-ai/v1/generate
+ * @return int|WP_Error New form post ID on success, WP_Error on failure.
+ */
+ public static function create_form( array $ai_response ) {
+ $title = sanitize_text_field( $ai_response['form_title'] ?? __( 'AI Generated Form', 'everest-forms' ) );
+ $fields = $ai_response['fields'] ?? [];
+
+ remove_all_filters( 'content_save_pre' );
+
+ // Saved as DRAFT — becomes active only when user clicks "Use This Form"
+ $post_id = wp_insert_post( [
+ 'post_title' => $title,
+ 'post_type' => 'everest_form',
+ 'post_status' => 'draft',
+ 'post_content' => '{}',
+ ] );
+
+ if ( is_wp_error( $post_id ) ) {
+ return $post_id;
+ }
+
+ $form_data = self::build_form_data( $post_id, $ai_response );
+
+ wp_update_post( [
+ 'ID' => $post_id,
+ 'post_content' => evf_encode( $form_data ),
+ ] );
+
+ return $post_id;
+ }
+
+ /**
+ * Rebuild an existing (draft) AI form in place from a refined AI response.
+ * Keeps the same form id/status so the preview and builder stay in sync.
+ *
+ * @param int $form_id Existing draft form id.
+ * @param array $ai_response Refined AI form schema.
+ * @return int|WP_Error The form id on success.
+ */
+ public static function update_form( int $form_id, array $ai_response ) {
+ $post = get_post( $form_id );
+ if ( ! $post || 'everest_form' !== $post->post_type ) {
+ return new WP_Error( 'invalid_form', __( 'Form not found.', 'everest-forms' ) );
+ }
+
+ remove_all_filters( 'content_save_pre' );
+
+ // Preserve per-field settings (e.g. label_hide) that were applied by a prior AI
+ // request but may be absent from this AI response. Merge before building form data.
+ $existing_data = evf_decode( $post->post_content );
+ $existing_index = self::index_fields_by_label_type( $existing_data['form_fields'] ?? array() );
+ $ai_response = self::merge_field_settings( $ai_response, $existing_index );
+
+ $title = sanitize_text_field( $ai_response['form_title'] ?? get_the_title( $form_id ) );
+ $form_data = self::build_form_data( $form_id, $ai_response );
+
+ wp_update_post( [
+ 'ID' => $form_id,
+ 'post_title' => $title,
+ 'post_content' => evf_encode( $form_data ),
+ ] );
+
+ return $form_id;
+ }
+
+ /**
+ * Publish a draft AI form — called when user clicks "Use This Form".
+ *
+ * @param int $form_id
+ * @return bool
+ */
+ public static function activate_form( int $form_id ): bool {
+ $post = get_post( $form_id );
+ if ( ! $post || 'everest_form' !== $post->post_type ) {
+ return false;
+ }
+
+ return (bool) wp_update_post( [
+ 'ID' => $form_id,
+ 'post_status' => 'publish',
+ ] );
+ }
+
+ /**
+ * Return summary of fields in a form for the preview modal.
+ *
+ * @param int $form_id
+ * @return array [ [ label, type, is_pro ], ... ]
+ */
+ public static function get_field_summary( int $form_id ): array {
+ $post = get_post( $form_id );
+ if ( ! $post ) {
+ return [];
+ }
+
+ $data = evf_decode( $post->post_content );
+ $fields = $data['form_fields'] ?? [];
+ $summary = [];
+
+ foreach ( $fields as $field ) {
+ $type = $field['type'] ?? '';
+ if ( in_array( $type, [ 'html', 'title', 'divider', 'hidden' ], true ) ) {
+ continue; // skip non-input fields from preview list
+ }
+ $summary[] = [
+ 'label' => $field['label'] ?? ucfirst( $type ),
+ 'type' => $type,
+ 'is_pro' => in_array( $type, self::$pro_fields, true ),
+ ];
+ }
+
+ return $summary;
+ }
+
+ // ── Form data builder ─────────────────────────────────────────────────────
+
+ /**
+ * Field types that are narrow enough to share a row (2-column layout).
+ * Everything else gets a full-width row.
+ */
+ private static $narrow_types = [
+ 'text', 'first-name', 'last-name', 'email', 'phone',
+ 'number', 'url', 'date-time', 'select', 'country',
+ 'hidden', 'yes-no', 'rating', 'color', 'range-slider',
+ ];
+
+ /**
+ * Forced pairs — if the current field type is a key and the NEXT field
+ * type is the value, always put them on the same row regardless of position.
+ */
+ private static $forced_pairs = [
+ 'first-name' => 'last-name',
+ 'last-name' => 'first-name',
+ ];
+
+ private static function build_form_data( int $form_id, array $ai ): array {
+ self::$file_upload_limited = false;
+ $built_fields = [];
+ $email_field_id = null;
+
+ // Pre-compute which step each field index belongs to (for multipart pairing)
+ $field_step_map = [];
+ foreach ( ( $ai['multipart_steps'] ?? [] ) as $step_idx => $step ) {
+ foreach ( ( $step['field_indices'] ?? [] ) as $fi ) {
+ $field_step_map[ $fi ] = $step_idx;
+ }
+ }
+
+ // Promote explicit recaptcha/hcaptcha/turnstile field requests to the form-level
+ // recaptcha_support setting. The AI sometimes returns these as a field type rather
+ // than setting enable_recaptcha, so we detect and convert them here.
+ foreach ( ( $ai['fields'] ?? [] ) as $ai_field ) {
+ $t = strtolower( sanitize_key( $ai_field['type'] ?? '' ) );
+ $l = strtolower( $ai_field['label'] ?? '' );
+ if ( in_array( $t, [ 'recaptcha', 'hcaptcha', 'turnstile' ], true )
+ || false !== strpos( $l, 'recaptcha' )
+ || false !== strpos( $l, 'hcaptcha' )
+ || false !== strpos( $l, 'turnstile' ) ) {
+ $ai['enable_recaptcha'] = true;
+ break;
+ }
+ }
+
+ // Build all field objects first so we can look ahead for smart pairing
+ $field_list = [];
+ $field_index = 0;
+ $file_upload_count = 0;
+ $is_pro_active = defined( 'EVF_PRO_VERSION' ) || class_exists( 'EVF_Pro' );
+ foreach ( ( $ai['fields'] ?? [] ) as $ai_field ) {
+ // Free tier: only one file-upload field is allowed per form.
+ if ( ! $is_pro_active && 'file-upload' === ( $ai_field['ty