PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
6.2.15 6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 All 197 releases
← All changes | app/Modules/Ai/AiFormBuilder.php +358 -43 6.2.2 → 6.2.15 View file →
@@ -1,9 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentForm\App\Modules\Ai;
4 4
5 -defined('ABSPATH') or die;
5 +defined('ABSPATH') || die;
6 6
7 7 use Exception;
8 8 use FluentForm\App\Helpers\Helper;
9 9 use FluentForm\App\Models\Form;
@@ -46,8 +46,10 @@
46 46 }
47 47 }
48 48
49 49 /**
50 + * Map the AI-generated field list into a persisted form.
51 + *
50 52 * @param array $form
51 53 * @return Form|\FluentForm\Framework\Database\Query\Builder
52 54 * @throws Exception
53 55 */
@@ -87,8 +89,10 @@
87 89 return $this->saveForm($fluentFormFields, $title, $hasStep, $isConversational, $customCss);
88 90 }
89 91
90 92 /**
93 + * Send the prompt to the AI service and return the decoded form fields.
94 + *
91 95 * @param array $args
92 96 * @return array response form fields
93 97 * @throws Exception
94 98 */
@@ -105,11 +109,13 @@
105 109 $queryArgs = [
106 110 'user_prompt' => $this->getUserPrompt($args),
107 111 'site_url' => site_url(),
108 112 'site_title' => get_bloginfo('name'),
113 + 'site_locale' => determine_locale(),
109 114 'has_pro' => Helper::hasPro(),
110 - 'has_payment' => $paymentSetting['status'] == 'yes',
111 - 'request_id' => uniqid('ff_ai_')
115 + 'has_payment' => 'yes' == $paymentSetting['status'],
116 + 'request_id' => uniqid('ff_ai_'),
117 + 'save_usage' => apply_filters('fluentform/ai_save_usage', true),
112 118 ];
113 119
114 120 $result = (new FluentFormAIAPI())->makeRequest($queryArgs);
115 121
@@ -115,11 +121,14 @@
115 121
116 122 if (is_wp_error($result)) {
117 123 throw new Exception(esc_html($result->get_error_message()));
118 124 }
119 -
125 +
120 126 $response = trim(Arr::get($result, 'response', ''), '"');
121 - if (false !== preg_match('/```json(.*?)```/s', $response, $matches)) {
127 + // preg_match() returns 0 when there is no fence and false only on error,
128 + // so this must test for an actual match — otherwise an unfenced (and
129 + // perfectly valid) JSON reply is replaced with an empty string.
130 + if (1 === preg_match('/```json(.*?)```/s', $response, $matches)) {
122 131 $response = trim($matches[1]);
123 132 }
124 133
125 134 $decoded = json_decode($response, true);
@@ -125,24 +134,26 @@
125 134 $decoded = json_decode($response, true);
126 135 if (json_last_error() !== JSON_ERROR_NONE || empty($decoded) || empty($decoded['fields'])) {
127 136 throw new Exception(esc_html__('Invalid response: Please try again!', 'fluentform'));
128 137 }
129 - return $decoded;
138 + return $this->applyPromptHints($decoded, $args);
130 139 }
131 -
140 +
132 141 protected function getDefaultFields()
133 142 {
134 143 if ($this->allDefaultFields) {
135 144 return $this->allDefaultFields;
136 145 }
137 - /**
138 - * @var \FluentForm\App\Services\FormBuilder\Components
139 - */
140 146 $components = $this->app->make('components');
141 147 $this->app->doAction('fluentform/editor_init', $components);
142 148 $editorComponents = $components->toArray();
143 - $general = Arr::get($editorComponents, 'general', []);
144 - $advanced = Arr::get($editorComponents, 'advanced', []);
149 + // Re-key by element name. The palette groups are keyed by element in
150 + // DefaultElements.php, but Components::sort() renumbers them 0..n for
151 + // the editor's JSON contract - so whether these arrive keyed or as a
152 + // list depends on whether anything rendered the palette earlier in the
153 + // request. resolveInput() matches on the key, so normalise here.
154 + $general = array_column(Arr::get($editorComponents, 'general', []), null, 'element');
155 + $advanced = array_column(Arr::get($editorComponents, 'advanced', []), null, 'element');
145 156 $container = Arr::get($editorComponents, 'container', []);
146 157
147 158 // Apply filter to get additional components
148 159 // The second parameter (true) is passed to prevent field loss when form ID is falsy loss some field
@@ -161,9 +172,9 @@
161 172 $payments = array_column($payments, null, 'element');
162 173 $this->allDefaultFields = array_merge($general, $payments, $advanced, ['container' => $container]);
163 174 return $this->allDefaultFields;
164 175 }
165 -
176 +
166 177 protected function processField($element, $field, $allFields)
167 178 {
168 179 if ('container' == $element) {
169 180 return $this->resolveContainerFields($field, $allFields);
@@ -186,9 +197,9 @@
186 197 if ($attributes = Arr::get($field, 'attributes')) {
187 198 $formatField['attributes'] = wp_parse_args($attributes, $matchedField['attributes']);
188 199 }
189 200
190 - $formatField['uniqElKey'] = "el_" . uniqid();
201 + $formatField['uniqElKey'] = 'el_' . uniqid();
191 202
192 203 if ('form_step' === $element) {
193 204 return $formatField;
194 205 }
@@ -224,12 +235,12 @@
224 235 $field['settings'] = wp_parse_args($targetSettings, $field['settings']);
225 236 }
226 237 }
227 238 }
228 -
239 +
229 240 return $formatField;
230 241 }
231 -
242 +
232 243 protected function resolveInput($field)
233 244 {
234 245 if (!is_array($field)) {
235 246 return false;
@@ -261,9 +272,9 @@
261 272 }
262 273 }
263 274 return false;
264 275 }
265 -
276 +
266 277 protected function getOptions($options = [])
267 278 {
268 279 $formattedOptions = [];
269 280 if (empty($options) || !is_array($options)) {
@@ -289,12 +300,12 @@
289 300 'label' => $label,
290 301 'value' => $value,
291 302 ];
292 303 }
293 -
304 +
294 305 return $formattedOptions;
295 306 }
296 -
307 +
297 308 protected function getBlankFormConfig()
298 309 {
299 310 $attributes = ['type' => 'form', 'predefined' => 'blank_form'];
300 311 $customForm = Form::resolvePredefinedForm($attributes);
@@ -302,9 +313,9 @@
302 313 $customForm['form_fields']['submitButton'] = $customForm['form']['submitButton'];
303 314 $customForm['form_fields'] = json_encode($customForm['form_fields']);
304 315 return $customForm;
305 316 }
306 -
317 +
307 318 protected function saveForm($formattedInputs, $title, $isStepForm = false, $isConversational = false, $customCss = '')
308 319 {
309 320 $customForm = $this->prepareCustomForm($formattedInputs, $isStepForm);
310 321 $data = Form::prepare($customForm);
@@ -309,11 +320,11 @@
309 320 $customForm = $this->prepareCustomForm($formattedInputs, $isStepForm);
310 321 $data = Form::prepare($customForm);
311 322
312 323 $form = $this->model->create($data);
313 - $form->title = $title ?: $form->title . ' (ChatGPT#' . $form->id . ')';
324 + $form->title = $title ? $title : $form->title . ' (ChatGPT#' . $form->id . ')';
314 325
315 - $formData = (object)$form->toArray();
326 + $formData = (object) $form->toArray();
316 327 if (FormFieldsParser::hasPaymentFields($formData)) {
317 328 $form->has_payment = 1;
318 329 }
319 330
@@ -331,9 +342,9 @@
331 342
332 343 if ($customCss = fluentformSanitizeCSS($customCss)) {
333 344 Helper::setFormMeta($form->id, '_custom_form_css', $customCss);
334 345 }
335 -
346 +
336 347 do_action('fluentform/inserted_new_form', $form->id, $data);
337 348 return $form;
338 349 }
339 350
@@ -386,8 +397,10 @@
386 397 return $matchedField;
387 398 }
388 399
389 400 /**
401 + * Build the step-wrapper skeleton used to wrap a multi-step form.
402 + *
390 403 * @return array
391 404 */
392 405 protected function getStepWrapper()
393 406 {
@@ -406,9 +419,9 @@
406 419 'enable_step_data_persistency' => 'no',
407 420 'enable_step_page_resume' => 'no',
408 421 ],
409 422 'editor_options' => [
410 - 'title' => 'Start Paging'
423 + 'title' => 'Start Paging',
411 424 ],
412 425 ],
413 426 'stepEnd' => [
414 427 'element' => 'step_end',
@@ -419,41 +432,343 @@
419 432 'settings' => [
420 433 'prev_btn' => [
421 434 'type' => 'default',
422 435 'text' => 'Previous',
423 - 'img_url' => ''
424 - ]
436 + 'img_url' => '',
437 + ],
425 438 ],
426 439 'editor_options' => [
427 - 'title' => 'End Paging'
440 + 'title' => 'End Paging',
428 441 ],
429 - ]
442 + ],
430 443 ];
431 444 }
432 -
445 +
433 446 private function getUserPrompt($args)
434 447 {
435 - $startingQuery = "Create a form for ";
448 + $startingQuery = 'Create a form for ';
436 449 $query = Sanitizer::sanitizeTextField(Arr::get($args, 'query'));
437 450 if (empty($query)) {
438 451 throw new Exception(esc_html__('Query is empty!', 'fluentform'));
439 452 }
440 -
441 - // Validate query length to prevent abuse (max 2000 characters)
442 - if (strlen($query) > 2000) {
443 - throw new Exception(esc_html__('Query is too long. Please limit your prompt to 2000 characters.', 'fluentform'));
453 +
454 + // Validate query length to prevent abuse (filterable; default 12000 characters)
455 + $maxQueryLength = (int) apply_filters('fluentform/ai_query_max_length', 12000);
456 + if (mb_strlen($query) > $maxQueryLength) {
457 + throw new Exception(esc_html(sprintf(
458 + /* translators: %d is the maximum allowed number of characters */
459 + __('Query is too long. Please limit your prompt to %d characters.', 'fluentform'),
460 + $maxQueryLength
461 + )));
444 462 }
445 -
463 +
446 464 $additionalQuery = Sanitizer::sanitizeTextField(Arr::get($args, 'additional_query'));
447 -
448 - // Validate additional query length (max 1000 characters)
449 - if ($additionalQuery && strlen($additionalQuery) > 1000) {
450 - throw new Exception(esc_html__('Additional query is too long. Please limit to 1000 characters.', 'fluentform'));
465 +
466 + // Validate additional query length (filterable; default 6000 characters)
467 + $maxAdditionalQueryLength = (int) apply_filters('fluentform/ai_additional_query_max_length', 6000);
468 + if ($additionalQuery && mb_strlen($additionalQuery) > $maxAdditionalQueryLength) {
469 + throw new Exception(esc_html(sprintf(
470 + /* translators: %d is the maximum allowed number of characters */
471 + __('Additional query is too long. Please limit to %d characters.', 'fluentform'),
472 + $maxAdditionalQueryLength
473 + )));
451 474 }
452 -
475 +
453 476 if ($additionalQuery) {
454 - $query .= "\n including questions for information like " . $additionalQuery . ".";
477 + $query .= "\n including questions for information like " . $additionalQuery . '.';
455 478 }
456 - return $startingQuery . $query;
479 + return $startingQuery . $query . $this->getPromptContractInstructions();
457 480 }
458 -
481 +
482 + private function getPromptContractInstructions()
483 + {
484 + return "\n\nReturn strict JSON only. The user's instructions may be written in any language. "
485 + . "Preserve labels and help text in the user's original language, but always return machine-readable field settings. "
486 + . 'For every field, explicitly set whether it is required in settings.validation_rules.required.value when the prompt marks it as required or optional. '
487 + . 'If the prompt lists allowed upload extensions, map them into settings.validation_rules.allowed_file_types.value. '
488 + . 'If the prompt asks for a multi-step form with sections, include form_step elements between sections.';
489 + }
490 +
491 + private function applyPromptHints(array $form, array $args)
492 + {
493 + $query = (string) Arr::get($args, 'query', '');
494 + $additionalQuery = (string) Arr::get($args, 'additional_query', '');
495 + $hints = $this->extractPromptFieldHints(trim($query . "\n" . $additionalQuery));
496 +
497 + if (!$hints) {
498 + return $form;
499 + }
500 +
501 + $hintIndex = 0;
502 + $form['fields'] = $this->applyHintsToFields(Arr::get($form, 'fields', []), $hints, $hintIndex);
503 +
504 + return $form;
505 + }
506 +
507 + private function applyHintsToFields(array $fields, array $hints, &$hintIndex)
508 + {
509 + foreach ($fields as &$field) {
510 + if (!is_array($field)) {
511 + continue;
512 + }
513 +
514 + if (count($field) === 1) {
515 + $field = reset($field);
516 + }
517 +
518 + $element = Arr::get($field, 'element');
519 +
520 + if ('container' === $element) {
521 + $columns = Arr::get($field, 'columns', []);
522 + foreach ($columns as &$column) {
523 + $column['fields'] = $this->applyHintsToFields(Arr::get($column, 'fields', []), $hints, $hintIndex);
524 + }
525 + $field['columns'] = $columns;
526 + continue;
527 + }
528 +
529 + if (in_array($element, ['form_step', 'section_break'])) {
530 + continue;
531 + }
532 +
533 + $hint = Arr::get($hints, $hintIndex);
534 + ++$hintIndex;
535 +
536 + if (!$hint) {
537 + continue;
538 + }
539 +
540 + $field = $this->mergeFieldHint($field, $hint);
541 + }
542 +
543 + return $fields;
544 + }
545 +
546 + private function mergeFieldHint(array $field, array $hint)
547 + {
548 + if (array_key_exists('required', $hint)) {
549 + Arr::set($field, 'settings.validation_rules.required.value', (bool) $hint['required']);
550 + }
551 +
552 + if (!empty($hint['help_message'])) {
553 + Arr::set($field, 'settings.help_message', $hint['help_message']);
554 + }
555 +
556 + if (!empty($hint['allowed_file_types']) && in_array(Arr::get($field, 'element'), ['input_file', 'input_image'])) {
557 + Arr::set($field, 'settings.validation_rules.allowed_file_types.value', array_values(array_unique($hint['allowed_file_types'])));
558 + }
559 +
560 + return $field;
561 + }
562 +
563 + private function extractPromptFieldHints($prompt)
564 + {
565 + if (!$prompt) {
566 + return [];
567 + }
568 +
569 + $lines = preg_split('/\R/u', $prompt);
570 + $hints = [];
571 +
572 + foreach ($lines as $line) {
573 + $line = trim(wp_strip_all_tags($line));
574 + $line = preg_replace('/^[\-\*\d\.\)\s]+/u', '', $line);
575 +
576 + if (!$line || !$this->looksLikeFieldLine($line)) {
577 + continue;
578 + }
579 +
580 + $attributesText = '';
581 + if (preg_match('/\(([^()]*)\)/u', $line, $matches)) {
582 + $attributesText = trim($matches[1]);
583 + }
584 +
585 + $hint = [];
586 + $normalizedLine = function_exists('mb_strtolower') ? mb_strtolower($line, 'UTF-8') : strtolower($line);
587 +
588 + if ($this->containsAnyPhrase($normalizedLine, $this->getRequiredKeywords())) {
589 + $hint['required'] = true;
590 + } elseif ($this->containsAnyPhrase($normalizedLine, $this->getOptionalKeywords())) {
591 + $hint['required'] = false;
592 + }
593 +
594 + $helpMessage = $this->extractHelpMessage($attributesText);
595 + if ($helpMessage) {
596 + $hint['help_message'] = $helpMessage;
597 + }
598 +
599 + if ($this->containsAnyPhrase($normalizedLine, $this->getFileUploadKeywords())) {
600 + $allowedTypes = $this->extractAllowedFileTypes($line);
601 + if ($allowedTypes) {
602 + $hint['allowed_file_types'] = $allowedTypes;
603 + }
604 + }
605 +
606 + $hints[] = $hint;
607 + }
608 +
609 + return $hints;
610 + }
611 +
612 + private function looksLikeFieldLine($line)
613 + {
614 + if (preg_match('/^(create|erstelle|crée|crear|criar|crea|maak|utw[oó]rz|oluştur|创建|作成)\b/ui', $line)) {
615 + return false;
616 + }
617 +
618 + if (preg_match('/^(section|abschnitt|secci[oó]n|sectione|seção|sectie|sekcja|b[oö]l[uü]m|章节|セクション)\b/ui', $line)) {
619 + return false;
620 + }
621 +
622 + if (false === strpos($line, '(')) {
623 + return false;
624 + }
625 +
626 + preg_match('/\(([^()]*)\)/u', $line, $matches);
627 + $attributesText = isset($matches[1]) ? $matches[1] : '';
628 +
629 + return $this->containsAnyPhrase(
630 + function_exists('mb_strtolower') ? mb_strtolower($attributesText, 'UTF-8') : strtolower($attributesText),
631 + $this->getFieldDescriptorKeywords()
632 + );
633 + }
634 +
635 + private function extractHelpMessage($attributesText)
636 + {
637 + if (!$attributesText) {
638 + return '';
639 + }
640 +
641 + if (preg_match('/(?:note|hint|hinweis|remarque|nota|nota bene|opmerking|uwaga|not|说明|備考)\s*:\s*["“]?(.+?)["”]?$/ui', $attributesText, $matches)) {
642 + return trim($matches[1], " \t\n\r\0\x0B\"'“”");
643 + }
644 +
645 + return '';
646 + }
647 +
648 + private function extractAllowedFileTypes($line)
649 + {
650 + preg_match_all('/\b(jpe?g|gif|png|tiff?|bmp|webp|svg|pdf|docx?|xlsx?|xls|csv|zip|rar|txt)\b/ui', $line, $matches);
651 +
652 + if (empty($matches[1])) {
653 + return [];
654 + }
655 +
656 + $types = array_map(function ($type) {
657 + $type = strtolower($type);
658 +
659 + if ('jpeg' === $type) {
660 + return 'jpg';
661 + }
662 +
663 + if ('tif' === $type) {
664 + return 'tiff';
665 + }
666 +
667 + return $type;
668 + }, $matches[1]);
669 +
670 + return array_values(array_unique($types));
671 + }
672 +
673 + private function containsAnyPhrase($text, array $phrases)
674 + {
675 + foreach ($phrases as $phrase) {
676 + if (false !== strpos($text, $phrase)) {
677 + return true;
678 + }
679 + }
680 +
681 + return false;
682 + }
683 +
684 + private function getRequiredKeywords()
685 + {
686 + return [
687 + 'required',
688 + 'mandatory',
689 + 'pflichtfeld',
690 + 'erforderlich',
691 + 'obligatoire',
692 + 'requis',
693 + 'obligatorio',
694 + 'obrigatório',
695 + 'obbligatorio',
696 + 'verplicht',
697 + 'wymagane',
698 + 'zorunlu',
699 + '必填',
700 + '必須',
701 + ];
702 + }
703 +
704 + private function getOptionalKeywords()
705 + {
706 + return [
707 + 'optional',
708 + 'facultatif',
709 + 'facoltativo',
710 + 'opcjonalne',
711 + 'isteğe bağlı',
712 + 'opcional',
713 + 'opcionales',
714 + '选填',
715 + '任意',
716 + ];
717 + }
718 +
719 + private function getFileUploadKeywords()
720 + {
721 + return [
722 + 'file upload',
723 + 'upload',
724 + 'datei',
725 + 'datei upload',
726 + 'foto upload',
727 + 'image upload',
728 + 'upload de fichier',
729 + 'carga de archivo',
730 + 'subida de archivo',
731 + 'carregamento de arquivo',
732 + 'caricamento file',
733 + 'bestandsupload',
734 + 'yükleme',
735 + '上传',
736 + ];
737 + }
738 +
739 + private function getFieldDescriptorKeywords()
740 + {
741 + return [
742 + 'text',
743 + 'textarea',
744 + 'email',
745 + 'date',
746 + 'file',
747 + 'upload',
748 + 'phone',
749 + 'number',
750 + 'radio',
751 + 'checkbox',
752 + 'select',
753 + 'dropdown',
754 + 'image',
755 + 'foto',
756 + 'bild',
757 + 'datei',
758 + 'telefon',
759 + 'mobil',
760 + 'correo',
761 + 'texte',
762 + 'texto',
763 + 'testo',
764 + 'fecha',
765 + 'fichier',
766 + 'archivo',
767 + 'caricamento',
768 + 'ficheiro',
769 + 'bestand',
770 + 'yükleme',
771 + '上传',
772 + ];
773 + }
459 774 }