REDROOM
PHP 8.2.31
Path:
Logout
Edit File
Size: 13.92 KB
Close
//home/nshryvcy/blissfulnepal.com/wp-content/plugins/wpforms-lite/src/Integrations/AI/Admin/Ajax/FormEditor.php
Text
Base64
<?php namespace WPForms\Integrations\AI\Admin\Ajax; use WPForms\Integrations\AI\Admin\Builder\FormEditor as BuilderFormEditor; use WPForms\Integrations\AI\API\FormEditor as FormEditorAPI; /** * AI Form Editor AJAX handler. * * @since 1.10.1 */ class FormEditor extends Base { /** * Form Editor API instance. * * @since 1.10.1 * * @var FormEditorAPI */ private $form_editor_api; /** * Initialize. * * @since 1.10.1 */ public function init(): void { parent::init(); $this->form_editor_api = new FormEditorAPI(); $this->form_editor_api->init(); $this->hooks(); } /** * Register hooks. * * @since 1.10.1 */ private function hooks(): void { add_action( 'wp_ajax_wpforms_ai_form_editor_process', [ $this, 'process' ] ); add_action( 'wp_ajax_wpforms_ai_form_editor_reset', [ $this, 'reset' ] ); } /** * Process form editor AI request. * * Unified handler for all scopes. * * @since 1.10.1 */ public function process(): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh if ( ! $this->validate_nonce() ) { wp_send_json_error( [ 'error' => esc_html__( 'Your session expired. Please reload the builder.', 'wpforms-lite' ) ] ); } $scope = sanitize_key( $this->get_post_data( 'scope' ) ); // The analyze scope is always allowed — it is the initial prompt, not a pipeline scope. if ( $scope !== 'analyze' ) { $allowed = array_keys( ( new BuilderFormEditor() )->get_allowed_scopes() ); if ( ! in_array( $scope, $allowed, true ) ) { wpforms_log( 'AI Form Editor: unknown scope', [ 'scope' => $scope ], [ 'type' => 'error' ] ); wp_send_json_error( [ 'error' => esc_html__( 'Invalid scope.', 'wpforms-lite' ) ] ); } } $form_id = absint( $this->get_post_data( 'formId', 'int' ) ); if ( empty( $form_id ) ) { wp_send_json_error( [ 'error' => esc_html__( 'No form ID found.', 'wpforms-lite' ) ] ); } if ( ! wpforms_current_user_can( 'edit_form_single', $form_id ) ) { wp_send_json_error( [ 'error' => esc_html__( 'You are not allowed to edit this form.', 'wpforms-lite' ) ] ); } $session_id = $this->get_post_data( 'sessionId' ); // The analyze scope intentionally sends an empty session ID on the first edit of a chat session. // The middleware generates a stable session ID and returns it in the response. // All subsequent scopes (fields, settings, etc.) receive a session ID from the analyze step. if ( $scope !== 'analyze' && empty( $session_id ) ) { wp_send_json_error( [ 'error' => esc_html__( 'Missing session ID.', 'wpforms-lite' ) ] ); } $batch_id = $this->get_post_data( 'batchId' ); $form_data = $this->get_post_data( 'formData', 'form_data' ); if ( ! is_array( $form_data ) ) { $form_data = []; } // For non-analyze scopes the prompt is an AI-generated instruction from the `analyze` step's scopePrompts, // not raw user input. History is only relevant for the `analyze` scope. [ $prompt, $history ] = $this->get_request_arguments( $scope ); if ( $this->is_empty_prompt( $prompt ) ) { wp_send_json_error( [ 'error' => esc_html__( 'Empty prompt.', 'wpforms-lite' ) ] ); } // Create a form revision before applying changes. if ( $scope === 'analyze' ) { wp_save_post_revision( $form_id ); } // Call the API. $response = $this->form_editor_api->process_scope( $scope, $session_id, $form_data, $prompt, $history, $batch_id ); // Check for errors. if ( ! empty( $response['error'] ) ) { wp_send_json_error( $response ); } if ( $scope === 'fields' ) { $response = $this->get_prepared_fields_response( $response, $form_id ); } wp_send_json_success( $response ); } /** * Reset form editor session. * * @since 1.10.1 */ public function reset(): void { if ( ! $this->validate_nonce() ) { wp_send_json_error( [ 'error' => esc_html__( 'Your session expired. Please reload the builder.', 'wpforms-lite' ) ] ); } wp_send_json_success(); } /** * Get scope-specific request arguments. * * For the `analyze` scope, retrieves prompt and validated conversation history. * For other scopes, retrieves the prompt only (history is not applicable). * * @since 1.10.1 * * @param string $scope Request scope. * * @return array{0: string, 1: array} Prompt and history. */ private function get_request_arguments( string $scope ): array { $prompt = $this->get_post_data( 'prompt' ); if ( $scope !== 'analyze' ) { return [ $prompt, [] ]; } $history = $this->get_post_data( 'history', 'json' ); if ( ! is_array( $history ) || ! $this->validate_history( $history ) ) { $history = []; } return [ $prompt, $history ]; } /** * Validate a conversation history format. * * @since 1.10.1 * * @param array $history History items. * * @return bool True if valid. */ private function validate_history( array $history ): bool { foreach ( $history as $item ) { if ( ! is_array( $item ) ) { return false; } if ( empty( $item['role'] ) || empty( $item['content'] ) ) { return false; } if ( ! in_array( $item['role'], [ 'user', 'assistant' ], true ) ) { return false; } } return true; } /** * Get prepared fields response with enriched and decoded field data. * * @since 1.10.1 * * @param array $response AI response data containing field changes. * @param int $form_id Form ID. * * @return array */ private function get_prepared_fields_response( array $response, int $form_id ): array { if ( ! empty( $response['changes']['fieldsToAdd'] ) ) { // Enrich fields with rendered HTML. $response['changes']['fieldsToAdd'] = $this->get_enriched_fields( (array) $response['changes']['fieldsToAdd'], $form_id ); // Replace AI-assigned IDs with real IDs in the message text. $response = $this->replace_ai_ids_in_message( $response ); } if ( empty( $response['changes']['fieldsToUpdate'] ) ) { return $response; } // Sanitize fields data. foreach ( $response['changes']['fieldsToUpdate'] as $key => $field_data ) { $field_data = $this->get_sanitized_field_data( $field_data ); // Re-pack choices into a sequential 0-indexed array so the JSON wire // stays a JS Array (not an Object) for `applyListUpdate()`. JS writes // its own 1-indexed `data-key` attributes on rebuild. if ( ! empty( $field_data['choices'] ) && is_array( $field_data['choices'] ) ) { $field_data['choices'] = $this->get_sanitized_choices( $field_data['choices'], 0 ); } $response['changes']['fieldsToUpdate'][ $key ] = $field_data; } return $response; } /** * Replace AI-assigned field IDs with real field IDs in the response message. * * The LLM generates message text referencing its own temporary IDs (e.g. "Email (ID #5)"). * After field enrichment assigns real IDs, this method replaces those references * so the user sees the actual field IDs in the chat. * * @since 1.10.1 * * @param array $response AI response with enriched fieldsToAdd and message. * * @return array Response with updated message text. */ private function replace_ai_ids_in_message( array $response ): array { if ( empty( $response['message'] ) || ! is_array( $response['message'] ) ) { return $response; } // Build ai_id → real id map from enriched fields. $id_map = []; foreach ( (array) $response['changes']['fieldsToAdd'] as $field ) { if ( isset( $field['ai_id'], $field['id'] ) && $field['ai_id'] !== $field['id'] ) { $id_map[ $field['ai_id'] ] = $field['id']; } } if ( empty( $id_map ) ) { return $response; } foreach ( [ 'title', 'text', 'notice', 'footer' ] as $key ) { if ( empty( $response['message'][ $key ] ) || ! is_string( $response['message'][ $key ] ) ) { continue; } $response['message'][ $key ] = preg_replace_callback( '/\(ID\s*#(\d+)\)/', static function ( array $matches ) use ( $id_map ) { $ai_id = (int) $matches[1]; return isset( $id_map[ $ai_id ] ) ? '(ID #' . $id_map[ $ai_id ] . ')' : $matches[0]; }, $response['message'][ $key ] ); } return $response; } /** * Get fields enriched with rendered HTML. * * @since 1.10.1 * * @param array $fields Fields to add data from AI response. * @param int $form_id Form ID. * * @return array */ private function get_enriched_fields( array $fields, int $form_id ): array { foreach ( $fields as $key => $field_data ) { $field_data = $this->get_sanitized_field_data( (array) $field_data ); // Re-index choices from 1 before HTML is rendered server-side. // New SFE fields are inserted via `preview_html` / `options_html`, // so 1-indexed keys are baked into the markup and survive the form save (#17447). if ( ! empty( $field_data['choices'] ) && is_array( $field_data['choices'] ) ) { $field_data['choices'] = $this->get_sanitized_choices( $field_data['choices'] ); } $field_data = $this->get_new_field_data_with_html( $field_data, $form_id ); if ( empty( $field_data ) ) { unset( $fields[ $key ] ); continue; } $fields[ $key ] = $field_data; } return $fields; } /** * Get sanitized field data with null values removed recursively. * * @since 1.10.1 * * @param array $field_data Field data from AI response. * * @return array Field data without null values. */ private function get_sanitized_field_data( array $field_data ): array { foreach ( $field_data as $key => $value ) { if ( $value === null ) { unset( $field_data[ $key ] ); continue; } if ( is_array( $value ) ) { $field_data[ $key ] = $this->get_sanitized_field_data( $value ); } } return $field_data; } /** * Get sanitized choices with sequential numeric keys. * * The AI middleware can return sparse or 0-indexed choices, but WPForms * expects sequential keys — payment radio/select fields use the key as the * submitted input value and bail out in format() when the value is "0" * (PHP's empty( "0" ) === true). * * The `fieldsToAdd` path uses the default 1-indexed start because the * sanitized array feeds server-rendered preview/options HTML, where the * keys become persisted choice indices. The `fieldsToUpdate` path uses a * 0-indexed start because the array is JSON-encoded on the wire and JS * `applyListUpdate()` expects a proper Array (not an Object) — JS writes * its own 1-indexed `data-key` attributes on rebuild. * * @since 1.10.1 * * @param array $choices Choices array from AI response. * @param int $start_index First numeric key in the returned array. * * @return array Choices re-indexed starting at $start_index. */ private function get_sanitized_choices( array $choices, int $start_index = 1 ): array { $sanitized = []; $index = $start_index; foreach ( $choices as $choice ) { $sanitized[ $index ] = $choice; ++$index; } return $sanitized; } /** * Get the new field data with rendered HTML (preview and options). * * Delegates to WPForms_Field::get_new_field_preview_html() and * WPForms_Field::get_new_field_options_html() for rendering, * suitable for batch field creation without per-field AJAX overhead. * * @since 1.10.1 * * @param array $field_data Field data from AI response. * @param int $form_id Form ID. * * @return array Empty array on failure, or array with field, preview, and options keys. */ private function get_new_field_data_with_html( array $field_data, int $form_id ): array { $field_type = sanitize_key( $field_data['type'] ?? '' ); if ( empty( $field_type ) ) { return []; } // Get the field type object (registered in WPForms_Field::common_hooks()). /** This filter is documented in src/Pro/Forms/Fields/Base/EntriesEdit.php. */ $field_obj = apply_filters( "wpforms_fields_get_field_object_{$field_type}", null ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName if ( ! $field_obj ) { return []; } $form_obj = wpforms()->obj( 'form' ); if ( ! $form_obj ) { return []; } // Store id generated by AI to use it later for field sorting. $field_data['ai_id'] = $field_data['id'] ?? null; // Assign a new unique field ID. $field_data['id'] = $form_obj->next_field_id( $form_id ); // Allow field ID 0 (first field); reject false/null from next_field_id(). if ( empty( $field_data['id'] ) && $field_data['id'] !== 0 ) { return []; } // Apply field defaults and filters. $field_data = $this->get_prepared_field_data( $field_data ); $field_data['preview_html'] = $field_obj->get_new_field_preview_html( $field_data ); $field_data['options_html'] = $field_obj->get_new_field_options_html( $field_data ); return $field_data; } /** * Get prepared field data with defaults and filters applied. * * @since 1.10.1 * * @param array $field_data Raw field data. * * @return array Prepared field data. */ private function get_prepared_field_data( array $field_data ): array { /** This filter is documented in includes/fields/class-base.php. */ $field_data = (array) apply_filters( 'wpforms_field_new_default', $field_data ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName /** This filter is documented in includes/fields/class-base.php. */ $field_required = (string) apply_filters( 'wpforms_field_new_required', '', $field_data ); // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName if ( ! empty( $field_required ) ) { $field_data['required'] = '1'; } return $field_data; } /** * Check if AJAX is a Smart Form Editor field-creation call. * * @since 1.10.1 * * @return bool */ public static function is_sfe_field_creation_ajax(): bool { if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) { return false; } if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['nonce'] ) ), 'wpforms-ai-nonce' ) ) { return false; } if ( empty( $_POST['action'] ) ) { return false; } $action = sanitize_text_field( wp_unslash( $_POST['action'] ) ); return $action === 'wpforms_ai_form_editor_process'; } }
Save
Close
Exit & Reset
Text mode: syntax highlighting auto-detects file type.
Directory Contents
Dirs: 0 × Files: 4
Delete Selected
Select All
Select None
Sort:
Name
Size
Modified
Enable drag-to-move
Name
Size
Perms
Modified
Actions
Base.php
3.01 KB
lrw-r--r--
2026-06-03 14:57:36
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Choices.php
1.17 KB
lrw-r--r--
2026-06-03 14:57:36
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
FormEditor.php
13.92 KB
lrw-r--r--
2026-06-03 14:57:36
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Forms.php
14.70 KB
lrw-r--r--
2026-06-03 14:57:36
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Zip Selected
If ZipArchive is unavailable, a
.tar
will be created (no compression).