Files

753 lines
25 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace FluentForm\App\Modules\Ai;
defined('ABSPATH') or die;
use Exception;
use FluentForm\App\Helpers\Helper;
use FluentForm\App\Models\Form;
use FluentForm\App\Models\FormMeta;
use FluentForm\App\Modules\Acl\Acl;
use FluentForm\App\Modules\Form\FormFieldsParser;
use FluentForm\App\Modules\Payments\PaymentHelper;
use FluentForm\App\Services\FluentConversational\Classes\Converter\Converter;
use FluentForm\App\Services\Form\FormService;
use FluentForm\Framework\Helpers\ArrayHelper as Arr;
use FluentForm\Framework\Support\Sanitizer;
class AiFormBuilder extends FormService
{
private $allDefaultFields = [];
public function __construct()
{
parent::__construct();
add_action('wp_ajax_fluentform_ai_create_form', [$this, 'buildForm'], 11, 0);
}
public function buildForm()
{
try {
Acl::verify('fluentform_forms_manager');
$form = $this->generateForm($this->app->request->all());
$form = $this->prepareAndSaveForm($form);
wp_send_json_success([
'formId' => $form->id,
'redirect_url' => admin_url(
'admin.php?page=fluent_forms&form_id=' . $form->id . '&route=editor'
),
'message' => __('Successfully created a form.', 'fluentform'),
], 200);
} catch (Exception $e) {
wp_send_json_error([
'message' => $e->getMessage(),
], 422);
}
}
/**
* @param array $form
* @return Form|\FluentForm\Framework\Database\Query\Builder
* @throws Exception
*/
protected function prepareAndSaveForm($form)
{
$allFields = $this->getDefaultFields();
$fluentFormFields = [];
$fields = Arr::get($form, 'fields', []);
$isConversational = Arr::isTrue($form, 'is_conversational');
$customCss = Arr::get($form, 'custom_css', '');
$hasStep = false;
$lastFieldIndex = count($fields) - 1;
$disableFields = array_keys($this->getDisabledComponents());
foreach ($fields as $index => $field) {
if (count($field) == 1) {
$field = reset($field);
}
if ($element = $this->resolveInput($field)) {
if (in_array($element, $disableFields)) {
continue;
}
if (!$hasStep && 'form_step' === $element) {
if (0 === $index || $lastFieldIndex === $index) {
continue;
}
$hasStep = true;
}
$fluentFormFields[] = $this->processField($element, $field, $allFields);
}
}
$fluentFormFields = array_filter($fluentFormFields);
if (!$fluentFormFields) {
throw new Exception(esc_html__('Empty form. Please try again!', 'fluentform'));
}
$title = Arr::get($form, 'title', '');
return $this->saveForm($fluentFormFields, $title, $hasStep, $isConversational, $customCss);
}
/**
* @param array $args
* @return array response form fields
* @throws Exception
*/
protected function generateForm($args)
{
$aiModel = Arr::get($args, 'ai_model', 'default');
$isUsingChatGpt = Helper::hasPro() && 'chat_gpt' == $aiModel && class_exists('FluentFormPro\classes\Chat\ChatFormBuilder');
if ($isUsingChatGpt) {
(new \FluentFormPro\classes\Chat\ChatFormBuilder())->buildForm();
}
$paymentSetting = PaymentHelper::getPaymentSettings();
$queryArgs = [
'user_prompt' => $this->getUserPrompt($args),
'site_url' => site_url(),
'site_title' => get_bloginfo('name'),
'site_locale' => determine_locale(),
'has_pro' => Helper::hasPro(),
'has_payment' => $paymentSetting['status'] == 'yes',
'request_id' => uniqid('ff_ai_')
];
$result = (new FluentFormAIAPI())->makeRequest($queryArgs);
if (is_wp_error($result)) {
throw new Exception(esc_html($result->get_error_message()));
}
$response = trim(Arr::get($result, 'response', ''), '"');
if (false !== preg_match('/```json(.*?)```/s', $response, $matches)) {
$response = trim($matches[1]);
}
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($decoded) || empty($decoded['fields'])) {
throw new Exception(esc_html__('Invalid response: Please try again!', 'fluentform'));
}
return $this->applyPromptHints($decoded, $args);
}
protected function getDefaultFields()
{
if ($this->allDefaultFields) {
return $this->allDefaultFields;
}
/**
* @var \FluentForm\App\Services\FormBuilder\Components
*/
$components = $this->app->make('components');
$this->app->doAction('fluentform/editor_init', $components);
$editorComponents = $components->toArray();
$general = Arr::get($editorComponents, 'general', []);
$advanced = Arr::get($editorComponents, 'advanced', []);
$container = Arr::get($editorComponents, 'container', []);
// Apply filter to get additional components
// The second parameter (true) is passed to prevent field loss when form ID is falsy loss some field
$editorComponents = apply_filters('fluentform/editor_components', [], true);
if ($generalExtra = Arr::get($editorComponents, 'general')) {
$generalExtra = array_column($generalExtra, null, 'element');
$general = array_merge($general, $generalExtra);
}
if ($advancedExtra = Arr::get($editorComponents, 'advanced')) {
$advancedExtra = array_column($advancedExtra, null, 'element');
$advanced = array_merge($advanced, $advancedExtra);
}
$payments = Arr::get($editorComponents, 'payments', []);
$payments = array_column($payments, null, 'element');
$this->allDefaultFields = array_merge($general, $payments, $advanced, ['container' => $container]);
return $this->allDefaultFields;
}
protected function processField($element, $field, $allFields)
{
if ('container' == $element) {
return $this->resolveContainerFields($field, $allFields);
}
$matchedField = Arr::get($allFields, $element);
if (!$matchedField) {
return [];
}
$formatField = $matchedField;
if ($settings = Arr::get($field, 'settings')) {
// Replace 'label' with 'admin_field_label' if 'label' is shorter
if (isset($settings['label']) && $adminFieldLabel = Arr::get($settings, 'admin_field_label')) {
if (strlen($settings['label']) < strlen($adminFieldLabel)) {
$settings['label'] = $adminFieldLabel;
}
}
$formatField['settings'] = wp_parse_args($settings, $matchedField['settings']);
}
if ($attributes = Arr::get($field, 'attributes')) {
$formatField['attributes'] = wp_parse_args($attributes, $matchedField['attributes']);
}
$formatField['uniqElKey'] = "el_" . uniqid();
if ('form_step' === $element) {
return $formatField;
}
if ($fieldName = Arr::get($field, 'attributes.name')) {
$formatField['attributes']['name'] = $fieldName;
}
if ($options = $this->getOptions(Arr::get($field, 'options'))) {
if (isset($formatField['settings']['advanced_options'])) {
$formatField['settings']['advanced_options'] = $options;
}
if ('ratings' == $element) {
$formatField['options'] = array_column($options, 'label', 'value');
}
}
if ('rangeslider' == $element) {
if ($min = Arr::get($field, 'min')) {
$formatField['attributes']['min'] = intval($min);
}
if ($max = intval(Arr::get($field, 'max', 10))) {
$formatField['attributes']['max'] = $max;
}
}
if (in_array($element, ['input_name', 'address']) && $fields = Arr::get($field, 'fields')) {
foreach ($formatField['fields'] as $name => &$field) {
if ($targetAttributes = Arr::get($fields, "$name.attributes")) {
$field['attributes'] = wp_parse_args($targetAttributes, $field['attributes']);
}
if ($targetSettings = Arr::get($fields, "$name.settings")) {
$field['settings'] = wp_parse_args($targetSettings, $field['settings']);
}
}
}
return $formatField;
}
protected function resolveInput($field)
{
if (!is_array($field)) {
return false;
}
$element = Arr::get($field, 'element');
$allElements = array_keys($this->getDefaultFields());
if (in_array($element, $allElements)) {
return $element;
}
$type = Arr::get($field, 'type');
if (!$type) {
return false;
}
$searchTags = fluentformLoadFile('Services/FormBuilder/ElementSearchTags.php');
$form = ['type' => ''];
$form = json_decode(json_encode($form));
$searchTags = apply_filters('fluentform/editor_element_search_tags', $searchTags, $form);
foreach ($searchTags as $inputKey => $tags) {
if (array_search($type, $tags) !== false) {
return $inputKey;
} else {
foreach ($tags as $tag) {
if (strpos($tag, $type) !== false) {
return $inputKey;
}
}
}
}
return false;
}
protected function getOptions($options = [])
{
$formattedOptions = [];
if (empty($options) || !is_array($options)) {
return $options;
}
foreach ($options as $key => $option) {
if (is_string($option) || is_numeric($option)) {
$value = $label = $option;
} elseif (is_array($option)) {
$label = Arr::get($option, 'label');
$value = Arr::get($option, 'value');
} else {
continue;
}
if (!$value || !$label) {
$value = $value ?? $label;
$label = $label ?? $value;
}
if (!$value || !$label) {
continue;
}
$formattedOptions[] = [
'label' => $label,
'value' => $value,
];
}
return $formattedOptions;
}
protected function getBlankFormConfig()
{
$attributes = ['type' => 'form', 'predefined' => 'blank_form'];
$customForm = Form::resolvePredefinedForm($attributes);
$customForm['form_fields'] = json_decode($customForm['form_fields'], true);
$customForm['form_fields']['submitButton'] = $customForm['form']['submitButton'];
$customForm['form_fields'] = json_encode($customForm['form_fields']);
return $customForm;
}
protected function saveForm($formattedInputs, $title, $isStepForm = false, $isConversational = false, $customCss = '')
{
$customForm = $this->prepareCustomForm($formattedInputs, $isStepForm);
$data = Form::prepare($customForm);
$form = $this->model->create($data);
$form->title = $title ?: $form->title . ' (ChatGPT#' . $form->id . ')';
$formData = (object)$form->toArray();
if (FormFieldsParser::hasPaymentFields($formData)) {
$form->has_payment = 1;
}
if ($isConversational) {
$formMeta = FormMeta::prepare(['type' => 'form', 'predefined' => 'conversational'], $customForm);
$form->fill([
'form_fields' => Converter::convertExistingForm($form),
])->save();
} else {
$form->save();
$formMeta = FormMeta::prepare(['type' => 'form', 'predefined' => 'blank_form'], $customForm);
}
FormMeta::store($form, $formMeta);
if ($customCss = fluentformSanitizeCSS($customCss)) {
Helper::setFormMeta($form->id, '_custom_form_css', $customCss);
}
do_action('fluentform/inserted_new_form', $form->id, $data);
return $form;
}
protected function prepareCustomForm($formattedInputs, $isStepForm)
{
$formattedInputs = fluentFormSanitizer($formattedInputs);
$customForm = $this->getBlankFormConfig();
$fields = json_decode($customForm['form_fields'], true);
$fields['form_fields']['fields'] = $formattedInputs;
$fields['form_fields']['submitButton'] = Arr::get($customForm, 'form.submitButton');
if ($isStepForm) {
$fields['form_fields']['stepsWrapper'] = $this->getStepWrapper();
}
$customForm['form_fields'] = json_encode($fields['form_fields']);
return $customForm;
}
protected function resolveContainerFields($field, $allFields)
{
$columns = Arr::get($field, 'columns');
$columnsCount = count($columns);
if (!$columnsCount || $columnsCount > 6) {
return [];
}
$matchedField = Arr::get($allFields, 'container.container_' . $columnsCount . '_col');
if (!$matchedField) {
return [];
}
$columnWidth = round(100 / $columnsCount, 2);
foreach ($columns as &$column) {
$formatedFields = [];
$fields = Arr::get($column, 'fields', []);
$columnWidth = Arr::get($column, 'width', $columnWidth);
foreach ($fields as $colField) {
$element = Arr::get($colField, 'element');
if ($columnField = $this->processField($element, $colField, $allFields)) {
$formatedFields[] = $columnField;
}
}
if ($formatedFields) {
$column['fields'] = $formatedFields;
$column['width'] = $columnWidth;
}
}
$matchedField['columns'] = $columns;
return $matchedField;
}
/**
* @return array
*/
protected function getStepWrapper()
{
return [
'stepStart' => [
'element' => 'step_start',
'attributes' => [
'id' => '',
'class' => '',
],
'settings' => [
'progress_indicator' => 'progress-bar',
'step_titles' => [],
'disable_auto_focus' => 'no',
'enable_auto_slider' => 'no',
'enable_step_data_persistency' => 'no',
'enable_step_page_resume' => 'no',
],
'editor_options' => [
'title' => 'Start Paging'
],
],
'stepEnd' => [
'element' => 'step_end',
'attributes' => [
'id' => '',
'class' => '',
],
'settings' => [
'prev_btn' => [
'type' => 'default',
'text' => 'Previous',
'img_url' => ''
]
],
'editor_options' => [
'title' => 'End Paging'
],
]
];
}
private function getUserPrompt($args)
{
$startingQuery = "Create a form for ";
$query = Sanitizer::sanitizeTextField(Arr::get($args, 'query'));
if (empty($query)) {
throw new Exception(esc_html__('Query is empty!', 'fluentform'));
}
// Validate query length to prevent abuse (max 2000 characters)
if (strlen($query) > 2000) {
throw new Exception(esc_html__('Query is too long. Please limit your prompt to 2000 characters.', 'fluentform'));
}
$additionalQuery = Sanitizer::sanitizeTextField(Arr::get($args, 'additional_query'));
// Validate additional query length (max 1000 characters)
if ($additionalQuery && strlen($additionalQuery) > 1000) {
throw new Exception(esc_html__('Additional query is too long. Please limit to 1000 characters.', 'fluentform'));
}
if ($additionalQuery) {
$query .= "\n including questions for information like " . $additionalQuery . ".";
}
return $startingQuery . $query . $this->getPromptContractInstructions();
}
private function getPromptContractInstructions()
{
return "\n\nReturn strict JSON only. The user's instructions may be written in any language. "
. "Preserve labels and help text in the user's original language, but always return machine-readable field settings. "
. "For every field, explicitly set whether it is required in settings.validation_rules.required.value when the prompt marks it as required or optional. "
. "If the prompt lists allowed upload extensions, map them into settings.validation_rules.allowed_file_types.value. "
. "If the prompt asks for a multi-step form with sections, include form_step elements between sections.";
}
private function applyPromptHints(array $form, array $args)
{
$query = (string) Arr::get($args, 'query', '');
$additionalQuery = (string) Arr::get($args, 'additional_query', '');
$hints = $this->extractPromptFieldHints(trim($query . "\n" . $additionalQuery));
if (!$hints) {
return $form;
}
$hintIndex = 0;
$form['fields'] = $this->applyHintsToFields(Arr::get($form, 'fields', []), $hints, $hintIndex);
return $form;
}
private function applyHintsToFields(array $fields, array $hints, &$hintIndex)
{
foreach ($fields as &$field) {
if (!is_array($field)) {
continue;
}
if (count($field) === 1) {
$field = reset($field);
}
$element = Arr::get($field, 'element');
if ('container' === $element) {
$columns = Arr::get($field, 'columns', []);
foreach ($columns as &$column) {
$column['fields'] = $this->applyHintsToFields(Arr::get($column, 'fields', []), $hints, $hintIndex);
}
$field['columns'] = $columns;
continue;
}
if (in_array($element, ['form_step', 'section_break'])) {
continue;
}
$hint = Arr::get($hints, $hintIndex);
$hintIndex++;
if (!$hint) {
continue;
}
$field = $this->mergeFieldHint($field, $hint);
}
return $fields;
}
private function mergeFieldHint(array $field, array $hint)
{
if (array_key_exists('required', $hint)) {
Arr::set($field, 'settings.validation_rules.required.value', (bool) $hint['required']);
}
if (!empty($hint['help_message'])) {
Arr::set($field, 'settings.help_message', $hint['help_message']);
}
if (!empty($hint['allowed_file_types']) && in_array(Arr::get($field, 'element'), ['input_file', 'input_image'])) {
Arr::set($field, 'settings.validation_rules.allowed_file_types.value', array_values(array_unique($hint['allowed_file_types'])));
}
return $field;
}
private function extractPromptFieldHints($prompt)
{
if (!$prompt) {
return [];
}
$lines = preg_split('/\R/u', $prompt);
$hints = [];
foreach ($lines as $line) {
$line = trim(wp_strip_all_tags($line));
$line = preg_replace('/^[\-\*\d\.\)\s]+/u', '', $line);
if (!$line || !$this->looksLikeFieldLine($line)) {
continue;
}
$attributesText = '';
if (preg_match('/\(([^()]*)\)/u', $line, $matches)) {
$attributesText = trim($matches[1]);
}
$hint = [];
$normalizedLine = function_exists('mb_strtolower') ? mb_strtolower($line, 'UTF-8') : strtolower($line);
if ($this->containsAnyPhrase($normalizedLine, $this->getRequiredKeywords())) {
$hint['required'] = true;
} elseif ($this->containsAnyPhrase($normalizedLine, $this->getOptionalKeywords())) {
$hint['required'] = false;
}
$helpMessage = $this->extractHelpMessage($attributesText);
if ($helpMessage) {
$hint['help_message'] = $helpMessage;
}
if ($this->containsAnyPhrase($normalizedLine, $this->getFileUploadKeywords())) {
$allowedTypes = $this->extractAllowedFileTypes($line);
if ($allowedTypes) {
$hint['allowed_file_types'] = $allowedTypes;
}
}
$hints[] = $hint;
}
return $hints;
}
private function looksLikeFieldLine($line)
{
if (preg_match('/^(create|erstelle|crée|crear|criar|crea|maak|utw[oó]rz|oluştur|创建|作成)\b/ui', $line)) {
return false;
}
if (preg_match('/^(section|abschnitt|secci[oó]n|sectione|seção|sectie|sekcja|b[oö]l[uü]m|章节|セクション)\b/ui', $line)) {
return false;
}
if (false === strpos($line, '(')) {
return false;
}
preg_match('/\(([^()]*)\)/u', $line, $matches);
$attributesText = isset($matches[1]) ? $matches[1] : '';
return $this->containsAnyPhrase(
function_exists('mb_strtolower') ? mb_strtolower($attributesText, 'UTF-8') : strtolower($attributesText),
$this->getFieldDescriptorKeywords()
);
}
private function extractHelpMessage($attributesText)
{
if (!$attributesText) {
return '';
}
if (preg_match('/(?:note|hint|hinweis|remarque|nota|nota bene|opmerking|uwaga|not|说明|備考)\s*:\s*["“]?(.+?)["”]?$/ui', $attributesText, $matches)) {
return trim($matches[1], " \t\n\r\0\x0B\"'“”");
}
return '';
}
private function extractAllowedFileTypes($line)
{
preg_match_all('/\b(jpe?g|gif|png|tiff?|bmp|webp|svg|pdf|docx?|xlsx?|xls|csv|zip|rar|txt)\b/ui', $line, $matches);
if (empty($matches[1])) {
return [];
}
$types = array_map(function ($type) {
$type = strtolower($type);
if ('jpeg' === $type) {
return 'jpg';
}
if ('tif' === $type) {
return 'tiff';
}
return $type;
}, $matches[1]);
return array_values(array_unique($types));
}
private function containsAnyPhrase($text, array $phrases)
{
foreach ($phrases as $phrase) {
if (false !== strpos($text, $phrase)) {
return true;
}
}
return false;
}
private function getRequiredKeywords()
{
return [
'required',
'mandatory',
'pflichtfeld',
'erforderlich',
'obligatoire',
'requis',
'obligatorio',
'obrigatório',
'obbligatorio',
'verplicht',
'wymagane',
'zorunlu',
'必填',
'必須'
];
}
private function getOptionalKeywords()
{
return [
'optional',
'facultatif',
'facoltativo',
'opcjonalne',
'isteğe bağlı',
'opcional',
'opcionales',
'选填',
'任意'
];
}
private function getFileUploadKeywords()
{
return [
'file upload',
'upload',
'datei',
'datei upload',
'foto upload',
'image upload',
'upload de fichier',
'carga de archivo',
'subida de archivo',
'carregamento de arquivo',
'caricamento file',
'bestandsupload',
'yükleme',
'上传'
];
}
private function getFieldDescriptorKeywords()
{
return [
'text',
'textarea',
'email',
'date',
'file',
'upload',
'phone',
'number',
'radio',
'checkbox',
'select',
'dropdown',
'image',
'foto',
'bild',
'datei',
'telefon',
'mobil',
'correo',
'texte',
'texto',
'testo',
'fecha',
'fichier',
'archivo',
'caricamento',
'ficheiro',
'bestand',
'yükleme',
'上传'
];
}
}