diff --git a/api/app/Console/Commands/FormTitleMigration.php b/api/app/Console/Commands/FormTitleMigration.php new file mode 100644 index 00000000..05c3d12a --- /dev/null +++ b/api/app/Console/Commands/FormTitleMigration.php @@ -0,0 +1,95 @@ +formMigration(); + + $this->formTemplateMigration(); + + $this->line('Migration Done'); + } + + public function formMigration() + { + $this->info('Starting forms migration...'); + + Form::chunk(100, function ($forms) { + foreach ($forms as $form) { + if ($form?->hide_title ?? false) { + continue; + } + + $properties = $form->properties ?? []; + + array_unshift($properties, [ + 'type' => 'nf-text', + 'content' => '

' . $form->title . '

', + 'name' => 'Title', + 'align' => $form->layout_rtl ? 'right' : 'left', + 'id' => Str::uuid() + ]); + + $form->properties = $properties; + $form->timestamps = false; + $form->save(); + } + }); + } + + public function formTemplateMigration() + { + $this->info('Starting forms template migration...'); + + Template::chunk(100, function ($templates) { + foreach ($templates as $template) { + $structure = $template->structure ?? []; + if (!$structure) { + continue; + } + + $properties = $structure['properties'] ?? []; + + array_unshift($properties, [ + 'type' => 'nf-text', + 'content' => '

' . $structure['title'] . '

', + 'name' => 'Title', + 'align' => isset($structure['layout_rtl']) && $structure['layout_rtl'] ? 'right' : 'left', + 'id' => Str::uuid() + ]); + + $structure['properties'] = $properties; + $template->structure = $structure; + $template->timestamps = false; + $template->save(); + } + }); + } +} diff --git a/api/app/Console/Commands/GenerateTemplate.php b/api/app/Console/Commands/GenerateTemplate.php index 9cfff1b9..71b45114 100644 --- a/api/app/Console/Commands/GenerateTemplate.php +++ b/api/app/Console/Commands/GenerateTemplate.php @@ -3,7 +3,8 @@ namespace App\Console\Commands; use App\Models\Template; -use App\Service\OpenAi\GptCompleter; +use App\Service\AI\Prompts\Form\GenerateFormPrompt; +use App\Service\AI\Prompts\Template\GenerateTemplateMetadataPrompt; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; @@ -15,7 +16,7 @@ class GenerateTemplate extends Command * * @var string */ - protected $signature = 'ai:make-form-template {prompt}'; + protected $signature = 'form:generate {prompt}'; /** * The console command description. @@ -26,190 +27,6 @@ class GenerateTemplate extends Command public const MAX_RELATED_TEMPLATES = 8; - public const FORM_STRUCTURE_PROMPT = <<<'EOD' - You are an AI assistant for OpnForm, a form builder and your job is to build a form for our user. - - Forms are represented as Json objects. Here's an example form: - ```json - { - "title": "Contact Us", - "properties": [ - { - "help": null, - "name": "What's your name?", - "type": "text", - "hidden": false, - "required": true, - "placeholder": "Steve Jobs" - }, - { - "help": "We will never share your email with anyone else.", - "name": "Email", - "type": "email", - "hidden": false, - "required": true, - "placeholder": "steve@apple.com" - }, - { - "help": null, - "name": "How would you rate your overall experience?", - "type": "select", - "hidden": false, - "select": { - "options": [ - {"name": 1, "value": 1}, - {"name": 2, "value": 2}, - {"name": 3, "value": 3}, - {"name": 4, "value": 4}, - {"name": 5, "value": 5} - ] - }, - "prefill": 5, - "required": true, - "placeholder": null - }, - { - "help": null, - "name": "Subject", - "type": "text", - "hidden": false, - "required": true, - "placeholder": null - }, - { - "help": null, - "name": "How can we help?", - "type": "text", - "hidden": false, - "required": true, - "multi_lines": true, - "placeholder": null, - "generates_uuid": false, - "max_char_limit": "2000", - "hide_field_name": false, - "show_char_limit": false - }, - { - "help": "Upload any relevant files here.", - "name": "Have any attachments?", - "type": "files", - "hidden": false, - "placeholder": null - } - ], - "description": "

Looking for a real person to speak to?

We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.

", - "re_fillable": false, - "use_captcha": false, - "redirect_url": null, - "submitted_text": "

Great, we've received your message. We'll get back to you as soon as we can :)

", - "uppercase_labels": false, - "submit_button_text": "Submit", - "re_fill_button_text": "Fill Again", - "color": "#64748b" - } - ``` - The form properties can only have one of the following types: 'text', 'number', 'rating', 'scale','slider', 'select', 'multi_select', 'date', 'files', 'checkbox', 'url', 'email', 'phone_number', 'signature'. - All form properties objects need to have the keys 'help', 'name', 'type', 'hidden', 'placeholder', 'prefill'. - The placeholder property is optional (can be "null") and is used to display a placeholder text in the input field. - The help property is optional (can be "null") and is used to display extra information about the field. - - For the type "select" and "multi_select", the input object must have a key "select" (or "multi_select") that's mapped to an object like this one: - ```json - { - "options": [ - {"name": 1, "value": 1}, - {"name": 2, "value": 2}, - {"name": 3, "value": 3}, - {"name": 4, "value": 4} - ] - } - ``` - - For "rating" you can set the field property "rating_max_value" to set the maximum value of the rating. - For "scale" you can set the field property "scale_min_value", "scale_max_value" and "scale_step_value" to set the minimum, maximum and step value of the scale. - For "slider" you can set the field property "slider_min_value", "slider_max_value" and "slider_step_value" to set the minimum, maximum and step value of the slider. - - If the form is too long, you can paginate it by adding a page break block in the list of properties: - ```json - { - "name":"Page Break", - "next_btn_text":"Next", - "previous_btn_text":"Previous", - "type":"nf-page-break", - } - ``` - - If you need to add more context to the form, you can add text blocks: - ```json - { - "name":"My Text", - "type":"nf-text", - "content": "

This is a text block.

" - } - ``` - - Give me the valid JSON object only, representing the following form: "[REPLACE]" - Do not ask me for more information about required properties or types, only suggest me a form structure. - EOD; - - public const FORM_DESCRIPTION_PROMPT = <<<'EOD' - You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users. - Give me some valid html code (using only h2, p, ul, li html tags) for the following form template page: "[REPLACE]". - - The html code should have the following structure: - - A paragraph explaining what the template is about - - A paragraph explaining why and when to use such a form - - A paragraph explaining who is the target audience and why it's a great idea to build this form - - A paragraph explaining that OpnForm is the best tool to build this form. They can duplicate this template in a few seconds, and integrate with many other tools through our webhook or zapier integration. - Each paragraph (except for the first one) MUST start with with a h2 tag containing a title for this paragraph. - EOD; - - public const FORM_SHORT_DESCRIPTION_PROMPT = <<<'EOD' - I own a form builder online named OpnForm. It's free to use. - Give me a 1 sentence description for the following form template page: "[REPLACE]". It should be short and concise, but still explain what the form is about. - EOD; - - public const FORM_INDUSTRY_PROMPT = <<<'EOD' - You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users. - I am creating a form template: "[REPLACE]". You must assign the template to industries. Return a list of industries (minimum 1, maximum 3 but only if very relevant) and order them by relevance (most relevant first). - - Here are the only industries you can choose from: [INDUSTRIES] - Do no make up any new type, only use the ones listed above. - - Reply only with a valid JSON array, being an array of string. Order assigned industries from the most relevant to the less relevant. - Ex: { "industries": ["banking_forms","customer_service_forms"]} - EOD; - - public const FORM_TYPES_PROMPT = <<<'EOD' - You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users. - I am creating a form template: "[REPLACE]". You must assign the template to one or more types. Return a list of types (minimum 1, maximum 3 but only if very accurate) and order them by relevance (most relevant first). - - Here are the only types you can choose from: [TYPES] - Do no make up any new type, only use the ones listed above. - - Reply only with a valid JSON array, being an array of string. Order assigned types from the most relevant to the less relevant. - Ex: { "types": ["consent_forms","award_forms"]} - EOD; - - public const FORM_QAS_PROMPT = <<<'EOD' - Now give me 4 to 6 question and answers to put on the form template page. The questions should be about the reasons for this template (when to use, why, target audience, goal etc.). - The questions should also explain why OpnForm is the best option to create this form (open-source, free to use, integrations etc). - Reply only with a valid JSON, being an array of object containing the keys "question" and "answer". - EOD; - - public const FORM_TITLE_PROMPT = <<<'EOD' - Finally give me a title for the template. It must contain or end with "template". It should be short and to the point, without any quotes. - EOD; - - public const FORM_IMG_KEYWORDS_PROMPT = <<<'EOD' - I want to add an image to illustrate this form template page. Give me a relevant search query for unsplash. Reply only with a valid JSON like this: - ```json - { - "search_query": "" - } - ``` - EOD; - /** * Execute the console command. * @@ -217,57 +34,25 @@ class GenerateTemplate extends Command */ public function handle() { - // Get form structure - $completer = (new GptCompleter(config('services.openai.api_key'))) - ->useStreaming() - ->setSystemMessage('You are an assistant helping to generate forms.'); - $completer->expectsJson()->completeChat([ - ['role' => 'user', 'content' => Str::of(self::FORM_STRUCTURE_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()], - ]); - $formData = $completer->getArray(); - $formData = self::cleanAiOutput($formData); + // Get form structure using the form prompt class + $formData = GenerateFormPrompt::run($this->argument('prompt')); - $completer->doesNotExpectJson(); - $formDescriptionPrompt = Str::of(self::FORM_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString(); - $formShortDescription = $completer->completeChat([ - ['role' => 'user', 'content' => Str::of(self::FORM_SHORT_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()], - ])->getString(); - // If description is between quotes, remove quotes - $formShortDescription = Str::of($formShortDescription)->replaceMatches('/^"(.*)"$/', '$1')->toString(); + // Generate all template metadata using the consolidated prompt + $metadata = GenerateTemplateMetadataPrompt::run($this->argument('prompt')); - // Get industry & types - $completer->expectsJson(); - $industry = $this->getIndustries($completer, $this->argument('prompt')); - $types = $this->getTypes($completer, $this->argument('prompt')); + // Extract metadata components + $formShortDescription = $metadata['short_description']; + $formDescription = $metadata['detailed_description']; + $formTitle = $metadata['title']; + $industry = $metadata['industries']; + $types = $metadata['types']; + $formQAs = $metadata['qa_content']; // Get Related Templates $relatedTemplates = $this->getRelatedTemplates($industry, $types); - // Now get description and QAs - $completer->doesNotExpectJson(); - $formDescription = $completer->completeChat([ - ['role' => 'user', 'content' => $formDescriptionPrompt], - ])->getHtml(); - - $completer->expectsJson(); - $formCoverKeywords = $completer->completeChat([ - ['role' => 'user', 'content' => $formDescriptionPrompt], - ['role' => 'assistant', 'content' => $formDescription], - ['role' => 'user', 'content' => self::FORM_IMG_KEYWORDS_PROMPT], - ])->getArray(); - $imageUrl = $this->getImageCoverUrl($formCoverKeywords['search_query']); - - $formQAs = $completer->completeChat([ - ['role' => 'user', 'content' => $formDescriptionPrompt], - ['role' => 'assistant', 'content' => $formDescription], - ['role' => 'user', 'content' => self::FORM_QAS_PROMPT], - ])->getArray(); - $completer->doesNotExpectJson(); - $formTitle = $completer->completeChat([ - ['role' => 'user', 'content' => $formDescriptionPrompt], - ['role' => 'assistant', 'content' => $formDescription], - ['role' => 'user', 'content' => self::FORM_TITLE_PROMPT], - ])->getString(); + // Get image cover URL + $imageUrl = $this->getImageCoverUrl($metadata['image_search_query']); $template = $this->createFormTemplate( $formData, @@ -280,11 +65,12 @@ class GenerateTemplate extends Command $types, $relatedTemplates ); - $this->info('/form-templates/' . $template->slug); // Set reverse related Templates $this->setReverseRelatedTemplates($template); + $this->info(front_url('/form-templates/' . $template->slug)); + return Command::SUCCESS; } @@ -303,30 +89,6 @@ class GenerateTemplate extends Command return null; } - private function getIndustries(GptCompleter $completer, string $formPrompt): array - { - $industriesString = Template::getAllIndustries()->pluck('slug')->join(', '); - - return $completer->completeChat([ - ['role' => 'user', 'content' => Str::of(self::FORM_INDUSTRY_PROMPT) - ->replace('[REPLACE]', $formPrompt) - ->replace('[INDUSTRIES]', $industriesString) - ->toString()], - ])->getArray()['industries']; - } - - private function getTypes(GptCompleter $completer, string $formPrompt): array - { - $typesString = Template::getAllTypes()->pluck('slug')->join(', '); - - return $completer->completeChat([ - ['role' => 'user', 'content' => Str::of(self::FORM_TYPES_PROMPT) - ->replace('[REPLACE]', $formPrompt) - ->replace('[TYPES]', $typesString) - ->toString()], - ])->getArray()['types']; - } - private function getRelatedTemplates(array $industries, array $types): array { $templateScore = []; @@ -383,36 +145,4 @@ class GenerateTemplate extends Command } } } - - public static function cleanAiOutput(array $formData): array - { - // Add property uuids, improve form with options - foreach ($formData['properties'] as &$property) { - $property['id'] = Str::uuid()->toString(); // Column ID - - // Fix types - if ($property['type'] == 'rating') { - $property['rating_max_value'] = $property['rating_max_value'] ?? 5; - } elseif ($property['type'] == 'scale') { - $property['scale_min_value'] = $property['scale_min_value'] ?? 1; - $property['scale_max_value'] = $property['scale_max_value'] ?? 5; - $property['scale_step_value'] = $property['scale_step_value'] ?? 1; - } elseif ($property['type'] == 'slider') { - $property['slider_min_value'] = $property['slider_min_value'] ?? 0; - $property['slider_max_value'] = $property['slider_max_value'] ?? 100; - $property['slider_step_value'] = $property['slider_step_value'] ?? 1; - } - - if (($property['type'] == 'select' && count($property['select']['options']) <= 4) - || ($property['type'] == 'multi_select' && count($property['multi_select']['options']) <= 4) - ) { - $property['without_dropdown'] = true; - } - } - - // Clean data - $formData['title'] = Str::of($formData['title'])->replace('"', '')->toString(); - - return $formData; - } } diff --git a/api/app/Http/Requests/AiGenerateFormRequest.php b/api/app/Http/Requests/AiGenerateFormRequest.php index 78ea1237..0f66ad4e 100644 --- a/api/app/Http/Requests/AiGenerateFormRequest.php +++ b/api/app/Http/Requests/AiGenerateFormRequest.php @@ -14,7 +14,7 @@ class AiGenerateFormRequest extends FormRequest public function rules() { return [ - 'form_prompt' => 'required|string|max:1000', + 'form_prompt' => 'required|string|max:4000', ]; } } diff --git a/api/app/Http/Requests/UserFormRequest.php b/api/app/Http/Requests/UserFormRequest.php index 1ed971c2..2de06a1a 100644 --- a/api/app/Http/Requests/UserFormRequest.php +++ b/api/app/Http/Requests/UserFormRequest.php @@ -56,7 +56,6 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest 'logo_picture' => 'url|nullable', 'dark_mode' => ['required', Rule::in(Form::DARK_MODE_VALUES)], 'color' => 'required|string', - 'hide_title' => 'required|boolean', 'uppercase_labels' => 'required|boolean', 'no_branding' => 'required|boolean', 'transparent_background' => 'required|boolean', diff --git a/api/app/Jobs/Form/GenerateAiForm.php b/api/app/Jobs/Form/GenerateAiForm.php index 9d397c55..c359a34a 100644 --- a/api/app/Jobs/Form/GenerateAiForm.php +++ b/api/app/Jobs/Form/GenerateAiForm.php @@ -2,15 +2,13 @@ namespace App\Jobs\Form; -use App\Console\Commands\GenerateTemplate; use App\Models\Forms\AI\AiFormCompletion; -use App\Service\OpenAi\GptCompleter; +use App\Service\AI\Prompts\Form\GenerateFormPrompt; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Str; class GenerateAiForm implements ShouldQueue { @@ -26,7 +24,6 @@ class GenerateAiForm implements ShouldQueue */ public function __construct(public AiFormCompletion $completion) { - } /** @@ -40,25 +37,17 @@ class GenerateAiForm implements ShouldQueue 'status' => AiFormCompletion::STATUS_PROCESSING, ]); - $completer = (new GptCompleter(config('services.openai.api_key'))) - ->useStreaming() - ->setSystemMessage('You are a robot helping to generate forms.') - ->expectsJson(); - try { - $completer->completeChat([ - ['role' => 'user', 'content' => Str::of(GenerateTemplate::FORM_STRUCTURE_PROMPT) - ->replace('[REPLACE]', $this->completion->form_prompt)->toString()], - ], 3000); + // Use the static run method to execute the prompt + $formData = GenerateFormPrompt::run($this->completion->form_prompt); $this->completion->update([ 'status' => AiFormCompletion::STATUS_COMPLETED, - 'result' => GenerateTemplate::cleanAiOutput($completer->getArray()) + 'result' => $formData ]); } catch (\Exception $e) { $this->onError($e); } - } /** @@ -73,7 +62,7 @@ class GenerateAiForm implements ShouldQueue { $this->completion->update([ 'status' => AiFormCompletion::STATUS_FAILED, - 'result' => ['error' => $e->getMessage()], + 'error' => $e->getMessage(), ]); } } diff --git a/api/app/Models/Forms/AI/AiFormCompletion.php b/api/app/Models/Forms/AI/AiFormCompletion.php index 5322d6f5..c0b69106 100644 --- a/api/app/Models/Forms/AI/AiFormCompletion.php +++ b/api/app/Models/Forms/AI/AiFormCompletion.php @@ -25,6 +25,7 @@ class AiFormCompletion extends Model 'status', 'result', 'ip', + 'error', ]; protected $attributes = [ diff --git a/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index aae7b69a..49e4e049 100644 --- a/api/app/Models/Forms/Form.php +++ b/api/app/Models/Forms/Form.php @@ -68,7 +68,6 @@ class Form extends Model implements CachableAttributes 'color', 'uppercase_labels', 'no_branding', - 'hide_title', 'transparent_background', // Custom Code diff --git a/api/app/Service/AI/Prompts/Form/GenerateFormPrompt.php b/api/app/Service/AI/Prompts/Form/GenerateFormPrompt.php new file mode 100644 index 00000000..0fcb83c1 --- /dev/null +++ b/api/app/Service/AI/Prompts/Form/GenerateFormPrompt.php @@ -0,0 +1,578 @@ + + {formPrompt} + + + Forms are represented as Json objects. There are several input types and layout block types (type start with nf-). + You can use for instance nf-text to add a title or text to the form using some basic html (h1, p, b, i, u etc). + Order of blocks matters. + + Available field types: + - text: Text input (use multi_lines: true for multi-line text) + - rich_text: Rich text input + - date: Date picker (use with_time: true to include time selection) + - url: URL input with validation + - phone_number: Phone number input + - email: Email input with validation + - checkbox: Single checkbox for yes/no (use use_toggle_switch: true for toggle switch) + - select: Dropdown selection (use without_dropdown: true for radio buttons, recommended for <5 options) + - multi_select: Multiple selection (use without_dropdown: true for checkboxes, recommended for <5 options) + - matrix: Matrix input with rows and columns + - number: Numeric input + - rating: Star rating + - scale: Numeric scale + - slider: Slider selection + - files: File upload + - signature: Signature pad + - barcode: Barcode scanner + - nf-text: Rich text content (not an input field) + - nf-page-break: Page break for multi-page forms + - nf-divider: Visual divider (not an input field) + - nf-image: Image element + - nf-code: Code block + + HTML formatting for nf-text: + - Headers:

,

for section titles and subtitles + - Text formatting: or for bold, or for italic, for underline, for strikethrough + - Links: link text for hyperlinks + - Lists:
  • item
for bullet lists,
  1. item
for numbered lists + - Colors: colored text for colored text + - Paragraphs:

paragraph text

for text blocks with spacing + Use these HTML tags to create well-structured and visually appealing form content. + + Field width options: + - width: "full" (default, takes entire width) + - width: "1/2" (takes half width) + - width: "1/3" (takes a third of the width) + - width: "2/3" (takes two thirds of the width) + - width: "1/4" (takes a quarter of the width) + - width: "3/4" (takes three quarters of the width) + Fields with width less than "full" will be placed on the same line if there's enough room. For example: + - Two 1/2 width fields will be placed side by side + - Three 1/3 width fields will be placed on the same line + - etc. + No need for lines width to be complete. Don't abuse putting multiple fields on the same line if it doens't make sense. For First name and Last name, it works well for instance. + + If the form is too long, you can paginate it by adding one or multiple page breaks (nf-page-break). + + Create a complete form with appropriate fields based on the description. Include: + - A clear `title` (internal for form admin) + - `nf-text` blocks to add a title or text to the form using some basic html (h1, p, b, i, u etc) + - Logical field grouping + - Required fields where necessary + - Help text for complex fields + - Appropriate validation + - Customized submission text + EOD; + + /** + * JSON schema for form output + */ + protected ?array $jsonSchema = [ + 'type' => 'object', + 'required' => ['title', 'properties', 're_fillable', 'use_captcha', 'redirect_url', 'submitted_text', 'uppercase_labels', 'submit_button_text', 're_fill_button_text', 'color'], + 'additionalProperties' => false, + 'properties' => [ + 'title' => [ + 'type' => 'string', + 'description' => 'The title of the form (default: "New Form")' + ], + 're_fillable' => [ + 'type' => 'boolean', + 'description' => 'Whether the form can be refilled after submission (default: false)' + ], + 'use_captcha' => [ + 'type' => 'boolean', + 'description' => 'Whether to use CAPTCHA for spam protection (default: false)' + ], + 'redirect_url' => [ + 'type' => ['string', 'null'], + 'description' => 'URL to redirect to after submission (default: null)' + ], + 'submitted_text' => [ + 'type' => 'string', + 'description' => 'Text to display after form submission (default: "

Thank you for your submission!

")' + ], + 'uppercase_labels' => [ + 'type' => 'boolean', + 'description' => 'Whether to display field labels in uppercase (default: false)' + ], + 'submit_button_text' => [ + 'type' => 'string', + 'description' => 'Text for the submit button (default: "Submit")' + ], + 're_fill_button_text' => [ + 'type' => 'string', + 'description' => 'Text for the refill button (default: "Fill Again")' + ], + 'color' => [ + 'type' => 'string', + 'description' => 'Primary color for the form (default: "#64748b")' + ], + 'properties' => [ + 'type' => 'array', + 'description' => 'Array of form fields and elements', + 'items' => [ + 'anyOf' => [ + ['$ref' => '#/definitions/textProperty'], + ['$ref' => '#/definitions/richTextProperty'], + ['$ref' => '#/definitions/dateProperty'], + ['$ref' => '#/definitions/urlProperty'], + ['$ref' => '#/definitions/phoneNumberProperty'], + ['$ref' => '#/definitions/emailProperty'], + ['$ref' => '#/definitions/checkboxProperty'], + ['$ref' => '#/definitions/selectProperty'], + ['$ref' => '#/definitions/multiSelectProperty'], + ['$ref' => '#/definitions/matrixProperty'], + ['$ref' => '#/definitions/numberProperty'], + ['$ref' => '#/definitions/ratingProperty'], + ['$ref' => '#/definitions/scaleProperty'], + ['$ref' => '#/definitions/sliderProperty'], + ['$ref' => '#/definitions/filesProperty'], + ['$ref' => '#/definitions/signatureProperty'], + ['$ref' => '#/definitions/barcodeProperty'], + ['$ref' => '#/definitions/nfTextProperty'], + ['$ref' => '#/definitions/nfPageBreakProperty'], + ['$ref' => '#/definitions/nfDividerProperty'], + ['$ref' => '#/definitions/nfImageProperty'], + ['$ref' => '#/definitions/nfCodeProperty'] + ] + ] + ] + ], + 'definitions' => [ + 'option' => [ + 'type' => 'object', + 'required' => ['name', 'id'], + 'additionalProperties' => false, + 'properties' => [ + 'name' => ['type' => 'string'], + 'id' => ['type' => 'string'] + ] + ], + 'selectOptions' => [ + 'type' => 'object', + 'required' => ['options'], + 'additionalProperties' => false, + 'properties' => [ + 'options' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/definitions/option'], + 'description' => 'Options for select fields' + ] + ] + ], + 'baseProperty' => [ + 'type' => 'object', + 'required' => ['name', 'help', 'hidden', 'required', 'placeholder', 'width'], + 'additionalProperties' => false, + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name/label of the field' + ], + 'help' => [ + 'type' => 'string', + 'description' => 'Help text for the field (default: null)' + ], + 'hidden' => [ + 'type' => 'boolean', + 'description' => 'Whether the field is hidden (default: false)' + ], + 'required' => [ + 'type' => 'boolean', + 'description' => 'Whether the field is required (default: false)' + ], + 'placeholder' => [ + 'type' => 'string', + 'description' => 'Placeholder text for the field. Leave empty if not needed (default: "")' + ], + 'width' => [ + 'type' => 'string', + 'enum' => ['full', '1/2', '1/3', '2/3', '1/4', '3/4'], + 'description' => 'Width of the field in the form layout. "full" takes the entire width, "1/2" takes half width, "1/3" takes a third, etc. (default: "full")', + ] + ] + ], + 'textProperty' => [ + 'type' => 'object', + 'required' => ['type', 'core', 'multi_lines', 'generates_uuid', 'max_char_limit', 'hide_field_name', 'show_char_limit'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['text']], + 'multi_lines' => [ + 'type' => 'boolean', + 'description' => 'Whether the text field should have multiple lines (default: false)', + ], + 'generates_uuid' => [ + 'type' => 'boolean', + 'description' => 'Whether the field should generate a UUID (default: false)' + ], + 'max_char_limit' => [ + 'type' => ['integer', 'string'], + 'description' => 'Maximum character limit for text fields (default: 500)' + ], + 'hide_field_name' => [ + 'type' => 'boolean', + 'description' => 'Whether to hide the field name (default: false)' + ], + 'show_char_limit' => [ + 'type' => 'boolean', + 'description' => 'Whether to show the character limit (default: false)' + ], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'richTextProperty' => [ + 'type' => 'object', + 'required' => ['type', 'core', 'max_char_limit'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['rich_text']], + 'max_char_limit' => [ + 'type' => ['integer', 'string'], + 'description' => 'Maximum character limit for rich text fields (default: 1000)' + ], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'dateProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'with_time'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['date']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'with_time' => [ + 'type' => 'boolean', + 'description' => 'Whether to include time selection with the date (default: false)', + ] + ] + ], + 'urlProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'max_char_limit'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['url']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'max_char_limit' => [ + 'type' => ['integer', 'string'], + 'description' => 'Maximum character limit for URL fields (default: 500)' + ] + ] + ], + 'phoneNumberProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['phone_number']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'emailProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'max_char_limit'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['email']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'max_char_limit' => [ + 'type' => ['integer', 'string'], + 'description' => 'Maximum character limit for email fields (default: 320)' + ] + ] + ], + 'checkboxProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'use_toggle_switch'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['checkbox']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'use_toggle_switch' => [ + 'type' => 'boolean', + 'description' => 'Whether to display the checkbox as a toggle switch (default: false)', + ] + ] + ], + 'selectProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'select', 'without_dropdown'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['select']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'select' => ['$ref' => '#/definitions/selectOptions'], + 'without_dropdown' => [ + 'type' => 'boolean', + 'description' => 'Whether to display select options as radio buttons instead of a dropdown using FlatSelectInput. Recommended for small choices (<5 options) (default: false)', + ] + ] + ], + 'multiSelectProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'multi_select', 'without_dropdown'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['multi_select']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'multi_select' => ['$ref' => '#/definitions/selectOptions'], + 'without_dropdown' => [ + 'type' => 'boolean', + 'description' => 'Whether to display multi-select options as checkboxes instead of a dropdown using FlatSelectInput. Recommended for small choices (<5 options) (default: false)', + ] + ] + ], + 'matrixProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'rows', 'columns'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['matrix']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'rows' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Rows for matrix fields (ex: ["Row 1"])' + ], + 'columns' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Columns for matrix fields (ex: ["1", "2", "3"])' + ] + ] + ], + 'numberProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['number']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'ratingProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'rating_max_value'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['rating']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'rating_max_value' => [ + 'type' => 'integer', + 'description' => 'Maximum rating for rating fields (default: 5)' + ] + ] + ], + 'scaleProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'scale_min_value', 'scale_max_value', 'scale_step_value'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['scale']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'scale_min_value' => [ + 'type' => 'integer', + 'description' => 'Minimum value for scale fields (default: 1)' + ], + 'scale_max_value' => [ + 'type' => 'integer', + 'description' => 'Maximum value for scale fields (default: 5)' + ], + 'scale_step_value' => [ + 'type' => 'integer', + 'description' => 'Step value for scale fields (default: 1)' + ] + ] + ], + 'sliderProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'slider_min_value', 'slider_max_value', 'slider_step_value'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['slider']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'slider_min_value' => [ + 'type' => 'integer', + 'description' => 'Minimum value for slider fields (default: 0)' + ], + 'slider_max_value' => [ + 'type' => 'integer', + 'description' => 'Maximum value for slider fields (default: 50)' + ], + 'slider_step_value' => [ + 'type' => 'integer', + 'description' => 'Step value for slider fields (default: 1)' + ] + ] + ], + 'filesProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['files']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'signatureProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['signature']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'barcodeProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'decoders'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['barcode']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'decoders' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Decoders for barcode fields (default: ["ean_reader", "ean_8_reader"])' + ] + ] + ], + 'nfTextProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'content'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['nf-text']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'content' => [ + 'type' => 'string', + 'description' => 'HTML content for text elements. Supports headers (

,

), formatting (, , , ), links (), lists (
    ,
      ), colors (), and paragraphs (

      ). Example: "

      Form Title

      Please fill out this form.

      "' + ] + ] + ], + 'nfPageBreakProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type', 'next_btn_text', 'previous_btn_text'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['nf-page-break']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + 'next_btn_text' => [ + 'type' => 'string', + 'description' => 'Text for the next button in page breaks (default: "Next")' + ], + 'previous_btn_text' => [ + 'type' => 'string', + 'description' => 'Text for the previous button in page breaks (default: "Previous")' + ] + ] + ], + 'nfDividerProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['nf-divider']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'nfImageProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['nf-image']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ], + 'nfCodeProperty' => [ + 'type' => 'object', + 'required' => ['core', 'type'], + 'additionalProperties' => false, + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['nf-code']], + 'core' => ['$ref' => '#/definitions/baseProperty'], + ] + ] + ] + ]; + + public function __construct( + public string $formPrompt + ) { + parent::__construct(); + } + + protected function getSystemMessage(): ?string + { + return 'You are an AI assistant specialized in creating form structures. Design intuitive, user-friendly forms that capture all necessary information based on the provided description.'; + } + + protected function getPromptTemplate(): string + { + return self::PROMPT_TEMPLATE; + } + + /** + * Override the execute method to automatically process the output + */ + public function execute(): array + { + $formData = parent::execute(); + return $this->processOutput($formData); + } + + /** + * Process the AI output to ensure it meets our requirements + */ + public function processOutput(array $formData): array + { + // Add unique identifiers to properties + if (isset($formData['properties']) && is_array($formData['properties'])) { + foreach ($formData['properties'] as $index => $property) { + // Add a unique ID to each property + $formData['properties'][$index]['id'] = Str::uuid()->toString(); + + // Flatten core properties if they exist + if (isset($property['core']) && is_array($property['core'])) { + foreach ($property['core'] as $coreKey => $coreValue) { + $formData['properties'][$index][$coreKey] = $coreValue; + } + // Remove the core property after flattening + unset($formData['properties'][$index]['core']); + } + } + } + + // Clean title data + if (isset($formData['title'])) { + // Remove quotes if the title is enclosed in them + $formData['title'] = preg_replace('/^["\'](.*)["\']$/', '$1', $formData['title']); + } + + ray($formData); + + return $formData; + } +} diff --git a/api/app/Service/AI/Prompts/Prompt.php b/api/app/Service/AI/Prompts/Prompt.php new file mode 100644 index 00000000..cb58efe1 --- /dev/null +++ b/api/app/Service/AI/Prompts/Prompt.php @@ -0,0 +1,146 @@ +newInstanceArgs($args); + return $instance->execute(); + } + + public function __construct() + { + $this->completer = new GptCompleter(null, 2, $this->model); + } + + protected function initialize(): void + { + if ($this->jsonSchema) { + $this->completer->setJsonSchema($this->jsonSchema); + } + + if ($this->getSystemMessage()) { + $this->completer->setSystemMessage($this->getSystemMessage()); + } + + if ($this->useStreaming) { + $this->completer->useStreaming(); + } + } + + /** + * Override this method to set a custom system message + */ + protected function getSystemMessage(): ?string + { + return null; + } + + /** + * Must return the prompt template with placeholders + */ + abstract protected function getPromptTemplate(): string; + + public function getCompleter(): GptCompleter + { + return $this->completer; + } + + public function execute(): mixed + { + $this->initialize(); + $prompt = $this->buildPrompt(); + + try { + $this->completer->completeChat( + [['role' => 'user', 'content' => $prompt]], + $this->maxTokens, + $this->temperature + ); + + return $this->jsonSchema ? $this->completer->getArray() : $this->completer->getString(); + } catch (\Exception $e) { + Log::error('Error while executing prompt', [ + 'exception' => $e, + 'prompt' => $prompt, + 'json_schema' => $this->jsonSchema ?? null + ]); + throw $e; + } + } + + protected function buildPrompt(): string + { + $template = $this->getPromptTemplate(); + $variables = $this->getPromptVariables(); + + return strtr($template, $variables); + } + + protected function getPromptVariables(): array + { + $variables = []; + $reflection = new ReflectionClass($this); + + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $name = $property->getName(); + $value = $property->getValue($this); + + if (is_scalar($value)) { + $variables['{' . $name . '}'] = (string) $value; + } + } + + return $variables; + } + + public function setTemperature(float $temperature): self + { + $this->temperature = $temperature; + return $this; + } + + public function setMaxTokens(int $maxTokens): self + { + $this->maxTokens = $maxTokens; + return $this; + } + + public function setGptCompleter(GptCompleter $completer): self + { + $this->completer = $completer; + return $this; + } + + public function useStreaming(): self + { + $this->useStreaming = true; + return $this; + } +} diff --git a/api/app/Service/AI/Prompts/Template/GenerateTemplateMetadataPrompt.php b/api/app/Service/AI/Prompts/Template/GenerateTemplateMetadataPrompt.php new file mode 100644 index 00000000..7e409597 --- /dev/null +++ b/api/app/Service/AI/Prompts/Template/GenerateTemplateMetadataPrompt.php @@ -0,0 +1,196 @@ +loadAvailableOptions(); + + parent::__construct(); + $this->buildJsonSchema(); + } + + /** + * Load available industries and types from the Template model + */ + protected function loadAvailableOptions(): void + { + $this->availableIndustries = Template::getAllIndustries()->pluck('slug')->toArray(); + $this->availableTypes = Template::getAllTypes()->pluck('slug')->toArray(); + } + + /** + * Dynamically build the JSON schema with enums for industries and types + */ + protected function buildJsonSchema(): void + { + $this->jsonSchema = [ + 'type' => 'object', + 'properties' => [ + 'short_description' => [ + 'type' => 'string', + 'description' => 'A concise single-sentence description of the form template' + ], + 'detailed_description' => [ + 'type' => 'string', + 'description' => 'Detailed HTML content describing the form template' + ], + 'title' => [ + 'type' => 'string', + 'description' => 'The title of the form template' + ], + 'industries' => [ + 'type' => 'array', + 'description' => 'List of industry slugs for the template', + 'items' => [ + 'type' => 'string', + 'enum' => $this->availableIndustries + ] + ], + 'types' => [ + 'type' => 'array', + 'description' => 'List of type slugs for the template', + 'items' => [ + 'type' => 'string', + 'enum' => $this->availableTypes + ] + ], + 'image_search_query' => [ + 'type' => 'string', + 'description' => 'Search query for Unsplash to find a relevant image' + ], + 'qa_content' => [ + 'type' => 'array', + 'description' => 'Q&A content for the template', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'question' => [ + 'type' => 'string', + 'description' => 'The question about the form template' + ], + 'answer' => [ + 'type' => 'string', + 'description' => 'The answer to the question' + ] + ], + 'required' => ['question', 'answer'], + 'additionalProperties' => false + ] + ] + ], + 'required' => [ + 'short_description', + 'detailed_description', + 'title', + 'industries', + 'types', + 'image_search_query', + 'qa_content' + ], + 'additionalProperties' => false + ]; + } + + protected function getSystemMessage(): ?string + { + return 'You are an assistant helping to generate comprehensive metadata for form templates. Create well-structured, informative content that explains the purpose, benefits, and target audience of the form template.'; + } + + protected function getPromptTemplate(): string + { + return self::PROMPT_TEMPLATE; + } + + protected function buildPrompt(): string + { + $template = $this->getPromptTemplate(); + + $industriesString = implode(', ', $this->availableIndustries); + $typesString = implode(', ', $this->availableTypes); + + return Str::of($template) + ->replace('{templatePrompt}', $this->templatePrompt) + ->replace('{availableIndustries}', $industriesString) + ->replace('{availableTypes}', $typesString) + ->toString(); + } + + /** + * Override the initialize method to ensure the options are loaded + */ + protected function initialize(): void + { + if (empty($this->availableIndustries) || empty($this->availableTypes)) { + throw new \InvalidArgumentException('Failed to load available industries and types from the database'); + } + + parent::initialize(); + } +} diff --git a/api/app/Service/OpenAi/GptCompleter.php b/api/app/Service/OpenAi/GptCompleter.php index 44e451b7..e217777c 100644 --- a/api/app/Service/OpenAi/GptCompleter.php +++ b/api/app/Service/OpenAi/GptCompleter.php @@ -26,13 +26,14 @@ class GptCompleter protected bool $expectsJson = false; - protected int $tokenUsed = 0; + protected int $inputTokens = 0; + protected int $outputTokens = 0; protected bool $useStreaming = false; - public function __construct(string $apiKey, protected int $retries = 2, protected string $model = self::AI_MODEL) + public function __construct(?string $apiKey = null, protected int $retries = 2, protected string $model = self::AI_MODEL) { - $this->openAi = \OpenAI::client($apiKey); + $this->openAi = \OpenAI::client($apiKey ?? config('services.openai.api_key')); } public function setAiModel(string $model) @@ -70,6 +71,20 @@ class GptCompleter return $this; } + public function setJsonSchema(array $schema): self + { + $this->completionInput['response_format'] = [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'response_schema', + 'strict' => true, + 'schema' => $schema + ] + ]; + + return $this; + } + public function completeChat(array $messages, int $maxTokens = 4096, float $temperature = 0.81, ?bool $exceptJson = null): self { if (! is_null($exceptJson)) { @@ -136,9 +151,14 @@ class GptCompleter return trim($this->result); } - public function getTokenUsed(): int + public function getInputTokens(): int { - return $this->tokenUsed; + return $this->inputTokens; + } + + public function getOutputTokens(): int + { + return $this->outputTokens; } protected function computeChatCompletion(array $messages, int $maxTokens = 4096, float $temperature = 0.81): self @@ -157,10 +177,12 @@ class GptCompleter 'temperature' => $temperature, ]; - if ($this->expectsJson) { + if ($this->expectsJson && !isset($this->completionInput['response_format'])) { $completionInput['response_format'] = [ 'type' => 'json_object', ]; + } elseif (isset($this->completionInput['response_format'])) { + $completionInput['response_format'] = $this->completionInput['response_format']; } $this->completionInput = $completionInput; @@ -174,23 +196,30 @@ class GptCompleter return $this->queryStreamedCompletion(); } - try { - Log::debug('Open AI query: '.json_encode($this->completionInput)); - $response = $this->openAi->chat()->create($this->completionInput); - } catch (ErrorException $errorException) { - // Retry once - Log::warning("Open AI error, retrying: {$errorException->getMessage()}"); - $response = $this->openAi->chat()->create($this->completionInput); - } - $this->tokenUsed += $response->usage->totalTokens; - $this->result = $response->choices[0]->message->content; + $attempt = 1; + $lastError = null; - return $this; + while ($attempt <= $this->retries) { + try { + Log::debug('Open AI query: ' . json_encode($this->completionInput)); + $response = $this->openAi->chat()->create($this->completionInput); + $this->inputTokens = $response->usage->promptTokens; + $this->outputTokens = $response->usage->completionTokens; + $this->result = $response->choices[0]->message->content; + return $this; + } catch (ErrorException $errorException) { + $lastError = $errorException; + Log::warning("Open AI error, retrying: {$errorException->getMessage()}"); + $attempt++; + } + } + + throw $lastError ?? new \Exception('Failed to complete OpenAI request after multiple attempts'); } protected function queryStreamedCompletion(): self { - Log::debug('Open AI query: '.json_encode($this->completionInput)); + Log::debug('Open AI query: ' . json_encode($this->completionInput)); $this->result = ''; $response = $this->openAi->chat()->createStreamed($this->completionInput); foreach ($response as $chunk) { diff --git a/api/database/factories/FormFactory.php b/api/database/factories/FormFactory.php index 91d505e0..750614dd 100644 --- a/api/database/factories/FormFactory.php +++ b/api/database/factories/FormFactory.php @@ -57,7 +57,6 @@ class FormFactory extends Factory 'width' => $this->faker->randomElement(Form::WIDTHS), 'dark_mode' => $this->faker->randomElement(Form::DARK_MODE_VALUES), 'color' => '#3B82F6', - 'hide_title' => false, 'no_branding' => false, 'uppercase_labels' => true, 'transparent_background' => false, diff --git a/api/database/migrations/2025_02_10_110324_remove_hide_titlle_from_forms.php b/api/database/migrations/2025_02_10_110324_remove_hide_titlle_from_forms.php new file mode 100644 index 00000000..1b0349e0 --- /dev/null +++ b/api/database/migrations/2025_02_10_110324_remove_hide_titlle_from_forms.php @@ -0,0 +1,27 @@ +dropColumn('hide_title'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('forms', function (Blueprint $table) { + $table->boolean('hide_title')->default(false); + }); + } +}; diff --git a/api/database/migrations/2025_03_06_054849_add_error_column_to_ai_form_completions_table.php b/api/database/migrations/2025_03_06_054849_add_error_column_to_ai_form_completions_table.php new file mode 100644 index 00000000..0ad3b183 --- /dev/null +++ b/api/database/migrations/2025_03_06_054849_add_error_column_to_ai_form_completions_table.php @@ -0,0 +1,27 @@ +text('error')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ai_form_completions', function (Blueprint $table) { + $table->dropColumn('error'); + }); + } +}; diff --git a/client/components/open/components/EditSubmissionModal.vue b/client/components/open/components/EditSubmissionModal.vue index 4bad0f2e..5b46cc75 100644 --- a/client/components/open/components/EditSubmissionModal.vue +++ b/client/components/open/components/EditSubmissionModal.vue @@ -7,10 +7,10 @@
      - { - return props.form.hide_title - ? "This option is disabled because the form title is already hidden" - : null -}) const shareUrl = computed(() => { - return advancedOptions.value.hide_title - ? props.form.share_url + "?hide_title=true" - : props.form.share_url + return props.form.share_url }) const embedPopupCode = computed(() => { const nfData = { diff --git a/client/components/pages/forms/show/UrlFormPrefill.vue b/client/components/pages/forms/show/UrlFormPrefill.vue index c0806e25..18525bb4 100644 --- a/client/components/pages/forms/show/UrlFormPrefill.vue +++ b/client/components/pages/forms/show/UrlFormPrefill.vue @@ -75,10 +75,9 @@ v-if="form" :theme="theme" :loading="false" - :show-hidden="true" :form="form" :fields="form.properties" - :url-prefill-preview="true" + :mode="FormMode.PREFILL" @submit="generateUrl" > @@ -72,6 +73,7 @@ import { focusOnFirstFormElement, useDarkMode } from '~/lib/forms/public-page' +import { FormMode } from "~/lib/forms/FormModeStrategy.js" const crisp = useCrisp() const formsStore = useFormsStore() diff --git a/client/pages/forms/[slug]/show/share.vue b/client/pages/forms/[slug]/show/share.vue index c4f7651a..ffaa3e6e 100644 --- a/client/pages/forms/[slug]/show/share.vue +++ b/client/pages/forms/[slug]/show/share.vue @@ -69,7 +69,6 @@ useOpnSeoMeta({ }) const shareFormConfig = ref({ - hide_title: false, auto_submit: false, }) diff --git a/client/pages/templates/[slug].vue b/client/pages/templates/[slug].vue index 1a9690d0..2de95f50 100644 --- a/client/pages/templates/[slug].vue +++ b/client/pages/templates/[slug].vue @@ -57,17 +57,15 @@
      Template cover image
      @@ -90,21 +88,19 @@
      -
      -
      -
      -

      - Template Preview -

      - -
      +
      +
      +

      + Template Preview +

      +
      @@ -132,7 +128,7 @@
      -
      +
      @@ -276,6 +272,7 @@ import Breadcrumb from "~/components/global/Breadcrumb.vue" import SingleTemplate from "../../components/pages/templates/SingleTemplate.vue" import { fetchTemplate } from "~/stores/templates.js" import FormTemplateModal from "~/components/open/forms/components/templates/FormTemplateModal.vue" +import { FormMode } from "~/lib/forms/FormModeStrategy.js" defineRouteRules({ swr: 3600,