Merge branch 'ai-forms'

This commit is contained in:
Julien Nahum 2023-03-27 15:22:10 +02:00
commit fd0ba3f2e0
21 changed files with 1097 additions and 14 deletions

View File

@ -58,3 +58,5 @@ STRIPE_SECRET=
MUX_WORKSPACE_ID= MUX_WORKSPACE_ID=
MUX_API_TOKEN= MUX_API_TOKEN=
OPEN_AI_API_KEY=

View File

@ -0,0 +1,246 @@
<?php
namespace App\Console\Commands;
use App\Models\Template;
use App\Service\OpenAi\GptCompleter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GenerateTemplate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ai:make-form-template {prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generates a new form template from a prompt';
const FORM_STRUCTURE_PROMPT = <<<EOD
I created a form builder. 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,
"prefill": null,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "Email",
"type": "email",
"hidden": false,
"prefill": null,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "How would you rate your overall experience?",
"type": "select",
"hidden": false,
"select": {
"options": [
{
"id": "Below Average",
"name": "Below Average"
},
{
"id": "Average",
"name": "Average"
},
{
"id": "Above Average",
"name": "Above Average"
}
]
},
"prefill": null,
"required": true,
"placeholder": null,
},
{
"help": null,
"name": "Subject",
"type": "text",
"hidden": false,
"prefill": null,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "How can we help?",
"type": "text",
"hidden": false,
"prefill": null,
"required": true,
"multi_lines": true,
"placeholder": null,
"generates_uuid": false,
"max_char_limit": "2000",
"hide_field_name": false,
"show_char_limit": false,
"generates_auto_increment_id": false
},
{
"help": null,
"name": "Have any attachments?",
"type": "files",
"hidden": false,
"prefill": null,
"placeholder": null
}
],
"description": "<p>Looking for a real person to speak to?</p><p>We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.</p>",
"re_fillable": false,
"use_captcha": false,
"redirect_url": null,
"submitted_text": "<p>Great, we've received your message. We'll get back to you as soon as we can :)</p>",
"uppercase_labels": false,
"submit_button_text": "Submit",
"re_fill_button_text": "Fill Again",
"color": "#3B82F6"
}
```
The form properties can have one of the following types: 'text', 'number', 'select', 'multi_select', 'date', 'files', 'checkbox', 'url', 'email', 'phone_number'.
All form properties objects need to have the keys 'help', 'name', 'type', 'hidden', 'placeholder', 'prefill'.
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": [
{"id":"Option 1","name":"Option 1"},
{"id":"Pption 2","name":"Option 2"}
]
}
```
For the type "number" you can set the property "is_rating" to "true" to turn it into a star rating input.
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",
}
```
Give me the JSON code only, for the following form: "[REPLACE]"
Do not ask me for more information about required properties or types, suggest me a form structure instead.
EOD;
const FORM_DESCRIPTION_PROMPT = <<<EOD
I own a form builder online named OpnForm. It's free to use. Give me a description for a template page for the following form: [REPLACE]. Explain what the form is about, and that it takes seconds to duplicate the template to create your own version it and to start getting some submissions.
EOD;
const FORM_QAS_PROMPT = <<<EOD
Now give me 3 to 5 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.) and OpnForm's usage. Reply only with a valid JSON, being an array of object containing the keys "question" and "answer".
EOD;
const FORM_TITLE_PROMPT = <<<EOD
Finally give me a title for the template. It should be short and to the point, without any quotes.
EOD;
const FORM_IMG_KEYWORDS_PROMPT = <<<EOD
I want to add an image to illustrate this form template page. Give me a releveant search query for unsplash. Reply only with a valid JSON like this:
```json
{
"search_query": ""
}
```
EOD;
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
// Get form structture
$completer = (new GptCompleter(config('services.openai.api_key')))
->setSystemMessage('You are a robot helping to generate forms.');
$completer->completeChat([
["role" => "user", "content" => Str::of(self::FORM_STRUCTURE_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()]
], 3000);
$formData = $completer->getArray();
// Now get description and QAs
$formDescriptionPrompt = Str::of(self::FORM_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString();
$formDescription = $completer->completeChat([
["role" => "user", "content" => $formDescriptionPrompt]
])->getString();
$formQAs = $completer->completeChat([
["role" => "user", "content" => $formDescriptionPrompt],
["role" => "assistant", "content" => $formDescription],
["role" => "user", "content" => self::FORM_QAS_PROMPT]
])->getArray();
$formTitle = $completer->completeChat([
["role" => "user", "content" => $formDescriptionPrompt],
["role" => "assistant", "content" => $formDescription],
["role" => "user", "content" => self::FORM_TITLE_PROMPT]
])->getString();
// Finally get keyworks for image cover
$formCoverKeyworks = $completer->completeChat([
["role" => "user", "content" => $formDescriptionPrompt],
["role" => "assistant", "content" => $formDescription],
["role" => "user", "content" => self::FORM_IMG_KEYWORDS_PROMPT]
])->getArray();
$imageUrl = $this->getImageCoverUrl($formCoverKeyworks['search_query']);
$template = $this->createFormTemplate($formData, $formTitle, $formDescription, $formQAs, $imageUrl);
$this->info('/templates/' . $template->slug);
return Command::SUCCESS;
}
/**
* Get an image cover URL for the template using unsplash API
*/
private function getImageCoverUrl($searchQuery): ?string
{
$url = 'https://api.unsplash.com/search/photos?query=' . urlencode($searchQuery) . '&client_id=' . config('services.unslash.access_key');
$response = Http::get($url)->json();
if (isset($response['results'][0]['urls']['regular'])) {
return $response['results'][0]['urls']['regular'];
}
return null;
}
private function createFormTemplate(array $formData, string $formTitle, string $formDescription, array $formQAs, ?string $imageUrl)
{
// Add property uuids
foreach ($formData['properties'] as &$property) {
$property['id'] = Str::uuid()->toString();
}
// Clean data
$formTitle = Str::of($formTitle)->replace('"', '')->toString();
return Template::create([
'name' => $formTitle,
'description' => $formDescription,
'questions' => $formQAs,
'structure' => $formData,
'image_url' => $imageUrl,
]);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Console\Commands\GenerateTemplate;
use App\Http\Controllers\Controller;
use App\Http\Requests\AiGenerateFormRequest;
use App\Service\OpenAi\GptCompleter;
use Illuminate\Support\Str;
class AiFormController extends Controller
{
public function generateForm(AiGenerateFormRequest $request)
{
$this->middleware('throttle:4,1');
$completer = (new GptCompleter(config('services.openai.api_key')))
->setSystemMessage('You are a robot helping to generate forms.');
$completer->completeChat([
["role" => "user", "content" => Str::of(GenerateTemplate::FORM_STRUCTURE_PROMPT)
->replace('[REPLACE]', $request->form_prompt)->toString()]
], 3000);
return $this->success([
'message' => 'Form successfully generated!',
'form' => $this->cleanOutput($completer->getArray())
]);
}
private function cleanOutput($formData)
{
// Add property uuids
foreach ($formData['properties'] as &$property) {
$property['id'] = Str::uuid()->toString();
}
return $formData;
}
}

View File

@ -8,8 +8,6 @@ class SpaController extends Controller
{ {
/** /**
* Get the SPA view. * Get the SPA view.
*
* @return \Illuminate\Http\Response
*/ */
public function __invoke(Request $request) public function __invoke(Request $request)
{ {

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AiGenerateFormRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'form_prompt' => 'required|string'
];
}
}

View File

@ -4,11 +4,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
use Stevebauman\Purify\Facades\Purify; use Stevebauman\Purify\Facades\Purify;
class Template extends Model class Template extends Model
{ {
use HasFactory; use HasFactory, HasSlug;
protected $fillable = [ protected $fillable = [
'name', 'name',
@ -29,4 +31,15 @@ class Template extends Model
// Strip out unwanted html // Strip out unwanted html
$this->attributes['description'] = Purify::clean($value); $this->attributes['description'] = Purify::clean($value);
} }
/**
* Config/options
*/
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->doNotGenerateSlugsOnUpdate()
->generateSlugsFrom('name')
->saveSlugsTo('slug');
}
} }

View File

@ -0,0 +1,126 @@
<?php
namespace App\Service\OpenAi;
use App\Service\OpenAi\Utils\JsonFixer;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use OpenAI\Client;
use OpenAI\Exceptions\ErrorException;
/**
* Handles a GPT completion prompt with or without insert tag.
* Also parses output.
*/
class GptCompleter
{
const AI_MODEL = 'gpt-3.5-turbo';
protected Client $openAi;
protected mixed $result;
protected array $completionInput;
protected ?string $systemMessage;
protected int $tokenUsed = 0;
public function __construct(string $apiKey, protected int $retries = 2)
{
$this->openAi = \OpenAI::client($apiKey);
}
public function setSystemMessage(string $systemMessage): self
{
$this->systemMessage = $systemMessage;
return $this;
}
public function completeChat(array $messages, int $maxTokens = 512, float $temperature = 0.81): self
{
$this->computeChatCompletion($messages, $maxTokens, $temperature)
->queryCompletion();
return $this;
}
public function getBool(): bool
{
switch (strtolower($this->result)) {
case 'true':
return true;
case 'false':
return false;
default:
throw new \InvalidArgumentException("Expected a boolean value, got {$this->result}");
}
}
public function getArray(): array
{
$payload = Str::of($this->result)->trim();
if ($payload->contains('```json')) {
$payload = $payload->after('```json')->before('```');
} else if ($payload->contains('```')) {
$payload = $payload->after('```')->before('```');
}
$payload = $payload->toString();
$exception = null;
for ($i = 0; $i < $this->retries; $i++) {
try {
$payload = (new JsonFixer)->fix($payload);
return json_decode($payload, true);
} catch (\Aws\Exception\InvalidJsonException $e) {
$exception = $e;
Log::warning("Invalid JSON, retrying:");
Log::warning($payload);
Log::warning(json_encode($this->completionInput));
$this->queryCompletion();
}
}
throw $exception;
}
public function getString(): string
{
return trim($this->result);
}
public function getTokenUsed(): int
{
return $this->tokenUsed;
}
protected function computeChatCompletion(array $messages, int $maxTokens = 512, float $temperature = 0.81): self
{
if (isset($this->systemMessage) && $messages[0]['role'] !== 'system') {
$messages = array_merge([[
'role' => 'system',
'content' => $this->systemMessage
]], $messages);
}
$completionInput = [
'model' => self::AI_MODEL,
'messages' => $messages,
'max_tokens' => $maxTokens,
'temperature' => $temperature
];
$this->completionInput = $completionInput;
return $this;
}
protected function queryCompletion(): self {
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;
return $this;
}
}

View File

@ -0,0 +1,272 @@
<?php
namespace App\Service\OpenAi\Utils;
/*
* This file is part of the PHP-JSON-FIXER package.
*
* (c) Jitendra Adhikari <jiten.adhikary@gmail.com>
* <https://github.com/adhocore>
*
* Licensed under MIT license.
*/
use App\Exceptions\Coursework\InvalidJsonException;
/**
* Attempts to fix truncated JSON by padding contextual counterparts at the end.
*
* @author Jitendra Adhikari <jiten.adhikary@gmail.com>
* @license MIT
*
* @link https://github.com/adhocore/php-json-fixer
*/
class JsonFixer
{
use PadsJson;
/** @var array Current token stack indexed by position */
protected $stack = [];
/** @var bool If current char is within a string */
protected $inStr = false;
/** @var bool Whether to throw Exception on failure */
protected $silent = false;
/** @var array The complementary pairs */
protected $pairs = [
'{' => '}',
'[' => ']',
'"' => '"',
];
/** @var int The last seen object `{` type position */
protected $objectPos = -1;
/** @var int The last seen array `[` type position */
protected $arrayPos = -1;
/** @var string Missing value. (Options: true, false, null) */
protected $missingValue = 'null';
/**
* Set/unset silent mode.
*
* @param bool $silent
*
* @return $this
*/
public function silent($silent = true)
{
$this->silent = (bool)$silent;
return $this;
}
/**
* Set missing value.
*
* @param mixed $value
*
* @return $this
*/
public function missingValue($value)
{
if ($value === null) {
$value = 'null';
} elseif (\is_bool($value)) {
$value = $value ? 'true' : 'false';
}
$this->missingValue = $value;
return $this;
}
/**
* Fix the truncated JSON.
*
* @param string $json The JSON string to fix.
*
* @return string Fixed JSON. If failed with silent then original JSON.
* @throws InvalidJsonException When fixing fails.
*
*/
public function fix($json)
{
list($head, $json, $tail) = $this->trim($json);
if (empty($json) || $this->isValid($json)) {
return $json;
}
if (null !== $tmpJson = $this->quickFix($json)) {
return $tmpJson;
}
$this->reset();
return $head . $this->doFix($json) . $tail;
}
protected function trim($json)
{
\preg_match('/^(\s*)([^\s]+)(\s*)$/', $json, $match);
$match += ['', '', '', ''];
$match[2] = \trim($json);
\array_shift($match);
return $match;
}
protected function isValid($json)
{
\json_decode($json);
return \JSON_ERROR_NONE === \json_last_error();
}
protected function quickFix($json)
{
if (\strlen($json) === 1 && isset($this->pairs[$json])) {
return $json . $this->pairs[$json];
}
if ($json[0] !== '"') {
return $this->maybeLiteral($json);
}
return $this->padString($json);
}
protected function reset()
{
$this->stack = [];
$this->inStr = false;
$this->objectPos = -1;
$this->arrayPos = -1;
}
protected function maybeLiteral($json)
{
if (!\in_array($json[0], ['t', 'f', 'n'])) {
return null;
}
foreach (['true', 'false', 'null'] as $literal) {
if (\strpos($literal, $json) === 0) {
return $literal;
}
}
// @codeCoverageIgnoreStart
return null;
// @codeCoverageIgnoreEnd
}
protected function doFix($json)
{
list($index, $char) = [-1, ''];
while (isset($json[++$index])) {
list($prev, $char) = [$char, $json[$index]];
$next = isset($json[$index + 1]) ? $json[$index + 1] : '';
if (!\in_array($char, [' ', "\n", "\r"])) {
$this->stack($prev, $char, $index, $next);
}
}
return $this->fixOrFail($json);
}
protected function stack($prev, $char, $index, $next)
{
if ($this->maybeStr($prev, $char, $index)) {
return;
}
$last = $this->lastToken();
if (\in_array($last, [',', ':', '"']) && \preg_match('/\"|\d|\{|\[|t|f|n/', $char)) {
$this->popToken();
}
if (\in_array($char, [',', ':', '[', '{'])) {
$this->stack[$index] = $char;
}
$this->updatePos($char, $index);
}
protected function lastToken()
{
return \end($this->stack);
}
protected function popToken($token = null)
{
// Last one
if (null === $token) {
return \array_pop($this->stack);
}
$keys = \array_reverse(\array_keys($this->stack));
foreach ($keys as $key) {
if ($this->stack[$key] === $token) {
unset($this->stack[$key]);
break;
}
}
}
protected function maybeStr($prev, $char, $index)
{
if ($prev !== '\\' && $char === '"') {
$this->inStr = !$this->inStr;
}
if ($this->inStr && $this->lastToken() !== '"') {
$this->stack[$index] = '"';
}
return $this->inStr;
}
protected function updatePos($char, $index)
{
if ($char === '{') {
$this->objectPos = $index;
} elseif ($char === '}') {
$this->popToken('{');
$this->objectPos = -1;
} elseif ($char === '[') {
$this->arrayPos = $index;
} elseif ($char === ']') {
$this->popToken('[');
$this->arrayPos = -1;
}
}
protected function fixOrFail($json)
{
$length = \strlen($json);
$tmpJson = $this->pad($json);
if ($this->isValid($tmpJson)) {
return $tmpJson;
}
if ($this->silent) {
return $json;
}
throw new InvalidJsonException(
\sprintf('Could not fix JSON (tried padding `%s`)', \substr($tmpJson, $length), $json)
);
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Service\OpenAi\Utils;
/**
* Attempts to fix truncated JSON by padding contextual counterparts at the end.
*
* @author Jitendra Adhikari <jiten.adhikary@gmail.com>
* @license MIT
*
* @internal
*
* @link https://github.com/adhocore/php-json-fixer
*/
trait PadsJson
{
public function pad($tmpJson)
{
if (!$this->inStr) {
$tmpJson = \rtrim($tmpJson, ',');
while ($this->lastToken() === ',') {
$this->popToken();
}
}
$tmpJson = $this->padLiteral($tmpJson);
$tmpJson = $this->padObject($tmpJson);
return $this->padStack($tmpJson);
}
protected function padLiteral($tmpJson)
{
if ($this->inStr) {
return $tmpJson;
}
$match = \preg_match('/(tr?u?e?|fa?l?s?e?|nu?l?l?)$/', $tmpJson, $matches);
if (!$match || null === $literal = $this->maybeLiteral($matches[1])) {
return $tmpJson;
}
return \substr($tmpJson, 0, 0 - \strlen($matches[1])) . $literal;
}
protected function padStack($tmpJson)
{
foreach (\array_reverse($this->stack, true) as $token) {
if (isset($this->pairs[$token])) {
$tmpJson .= $this->pairs[$token];
}
}
return $tmpJson;
}
protected function padObject($tmpJson)
{
if (!$this->objectNeedsPadding($tmpJson)) {
return $tmpJson;
}
$part = \substr($tmpJson, $this->objectPos + 1);
if (\preg_match('/(\s*\"[^"]+\"\s*:\s*[^,]+,?)+$/', $part, $matches)) {
return $tmpJson;
}
if ($this->inStr) {
$tmpJson .= '"';
}
$tmpJson = $this->padIf($tmpJson, ':');
$tmpJson = $tmpJson . $this->missingValue;
if ($this->lastToken() === '"') {
$this->popToken();
}
return $tmpJson;
}
protected function objectNeedsPadding($tmpJson)
{
$last = \substr($tmpJson, -1);
$empty = $last === '{' && !$this->inStr;
return !$empty && $this->arrayPos < $this->objectPos;
}
protected function padString($string)
{
$last = \substr($string, -1);
$last2 = \substr($string, -2);
if ($last2 === '\"' || $last !== '"') {
return $string . '"';
}
// @codeCoverageIgnoreStart
return null;
// @codeCoverageIgnoreEnd
}
protected function padIf($string, $substr)
{
if (\substr($string, 0 - \strlen($substr)) !== $substr) {
return $string . $substr;
}
return $string;
}
}

View File

@ -36,6 +36,7 @@
"laravel/vapor-ui": "^1.5", "laravel/vapor-ui": "^1.5",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"openai-php/client": "^0.3.5",
"sentry/sentry-laravel": "^2.11.0", "sentry/sentry-laravel": "^2.11.0",
"spatie/laravel-sitemap": "^6.0", "spatie/laravel-sitemap": "^6.0",
"spatie/laravel-sluggable": "^3.0", "spatie/laravel-sluggable": "^3.0",

83
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "417c01c6f73f7e870992a7d71e75f363", "content-hash": "54e1e706f48dc10e931a4411e310be5c",
"packages": [ "packages": [
{ {
"name": "asm89/stack-cors", "name": "asm89/stack-cors",
@ -4972,6 +4972,87 @@
], ],
"time": "2022-06-22T07:13:36+00:00" "time": "2022-06-22T07:13:36+00:00"
}, },
{
"name": "openai-php/client",
"version": "v0.3.5",
"source": {
"type": "git",
"url": "https://github.com/openai-php/client.git",
"reference": "0bb01e93fbd1f155af1c5a70caa53252dd13478f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/openai-php/client/zipball/0bb01e93fbd1f155af1c5a70caa53252dd13478f",
"reference": "0bb01e93fbd1f155af1c5a70caa53252dd13478f",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.5.0",
"php": "^8.1.0"
},
"require-dev": {
"laravel/pint": "^1.6.0",
"nunomaduro/collision": "^7.0.5",
"pestphp/pest": "^2.0.0",
"pestphp/pest-plugin-arch": "^2.0.0",
"pestphp/pest-plugin-mock": "^2.0.0",
"phpstan/phpstan": "^1.10.3",
"rector/rector": "^0.14.8",
"symfony/var-dumper": "^6.2.7"
},
"type": "library",
"autoload": {
"files": [
"src/OpenAI.php"
],
"psr-4": {
"OpenAI\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API",
"keywords": [
"GPT-3",
"api",
"client",
"codex",
"dall-e",
"language",
"natural",
"openai",
"php",
"processing",
"sdk"
],
"support": {
"issues": "https://github.com/openai-php/client/issues",
"source": "https://github.com/openai-php/client/tree/v0.3.5"
},
"funding": [
{
"url": "https://www.paypal.com/paypalme/enunomaduro",
"type": "custom"
},
{
"url": "https://github.com/gehrisandro",
"type": "github"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
}
],
"time": "2023-03-08T06:39:38+00:00"
},
{ {
"name": "phenx/php-font-lib", "name": "phenx/php-font-lib",
"version": "0.5.4", "version": "0.5.4",

View File

@ -48,6 +48,15 @@ return [
'worker' => env('NOTION_WORKER','https://notion-forms-worker.notionforms.workers.dev/v1') 'worker' => env('NOTION_WORKER','https://notion-forms-worker.notionforms.workers.dev/v1')
], ],
'openai' => [
'api_key' => env('OPEN_AI_API_KEY'),
],
'unslash' => [
'access_key' => env('UNSPLASH_ACCESS_KEY'),
'secret_key' => env('UNSPLASH_SECRET_KEY'),
],
'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'), 'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'),
'amplitude_code' => env('AMPLITUDE_CODE'), 'amplitude_code' => env('AMPLITUDE_CODE'),
'crisp_website_id' => env('CRISP_WEBSITE_ID'), 'crisp_website_id' => env('CRISP_WEBSITE_ID'),

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('templates', function (Blueprint $table) {
// Make image_url nullable
$table->string('image_url')->nullable()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('templates', function (Blueprint $table) {
$table->string('image_url')->nullable(false)->change();
});
}
};

View File

@ -163,7 +163,7 @@ export default {
}, },
/** /**
* Returns true if we're on the last page * Returns true if we're on the last page
* @returns {boolean} * @returns {boolean}xs
*/ */
isLastPage() { isLastPage() {
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1) return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)

View File

@ -0,0 +1,114 @@
<template>
<modal :show="show" @close="$emit('close')" :closeable="!aiForm.busy">
<template #icon>
<template v-if="state=='default'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10 text-blue">
<path fill-rule="evenodd"
d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z"
clip-rule="evenodd"/>
</svg>
</template>
<template v-else-if="state=='ai'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path fill-rule="evenodd"
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd"/>
</svg>
</template>
</template>
<template #title>
<template v-if="state=='default'">
Choose a base for your form
</template>
<template v-else-if="state=='ai'">
AI-powered form generator
</template>
</template>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8" v-if="state=='default'">
<div class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50"
@click="showInitialFormModal=false">
<div class="p-4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path d="M1.5 8.67v8.58a3 3 0 003 3h15a3 3 0 003-3V8.67l-8.928 5.493a3 3 0 01-3.144 0L1.5 8.67z"/>
<path d="M22.5 6.908V6.75a3 3 0 00-3-3h-15a3 3 0 00-3 3v.158l9.714 5.978a1.5 1.5 0 001.572 0L22.5 6.908z"/>
</svg>
</div>
<p class="font-medium">Start from a simple contact form</p>
</div>
<div v-if="aiFeaturesEnabled" class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50" @click="state='ai'">
<div class="p-4 relative">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path fill-rule="evenodd"
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd"/>
</svg>
</div>
<p class="font-medium text-blue-700">Use our AI to create the form</p>
<span class="text-xs text-gray-500">(1 min)</span>
</div>
<div class="rounded-md border p-4 flex flex-col items-center cursor-pointer hover:bg-gray-50 relative">
<div class="p-4 relative">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 text-blue-500">
<path
d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z"/>
</svg>
</div>
<p class="font-medium">Start from a template</p>
<router-link :to="{name:'templates'}" class="absolute inset-0"/>
</div>
</div>
<div v-else-if="state=='ai'">
<a class="absolute top-4 left-4" href="#" @click.prevent="state='default'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 inline -mt-1">
<path fill-rule="evenodd" d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z" clip-rule="evenodd" />
</svg>
Back
</a>
<text-area-input label="Form Description" :form="aiForm" name="form_prompt" help="Give us a description of the form you want to build (the more details the better)"
placeholder="A simple contact form, with a name, email and message field" />
<v-button class="w-full" @click.prevent="generateForm" :loading="aiForm.busy">
Generate a form
</v-button>
<p class="text-gray-500 text-xs text-center mt-1">~60 sec</p>
</div>
</modal>
</template>
<script>
import Loader from "../../../common/Loader.vue";
import Form from "vform";
export default {
name: 'CreateFormBaseModal',
components: {Loader},
props: {
show: {type: Boolean, required: true},
},
data: () => ({
state: 'default',
aiForm: new Form({
form_prompt: ''
})
}),
computed: {
aiFeaturesEnabled() {
return window.config.ai_features_enabled
}
},
methods: {
generateForm() {
this.aiForm.post('/api/forms/ai/generate').then(response => {
this.alertSuccess(response.data.message)
this.$emit('form-generated', response.data.form)
this.$emit('close')
}).catch(error => {
this.alertError(error.data.message)
this.state = 'default'
})
}
}
}
</script>

View File

@ -2,6 +2,8 @@
<div class="flex flex-wrap flex-col"> <div class="flex flex-wrap flex-col">
<transition v-if="stateReady" name="fade" mode="out-in"> <transition v-if="stateReady" name="fade" mode="out-in">
<div key="2"> <div key="2">
<create-form-base-modal @form-generated="formGenerated" :show="showInitialFormModal"
@close="showInitialFormModal=false"/>
<form-editor v-if="!workspacesLoading" ref="editor" <form-editor v-if="!workspacesLoading" ref="editor"
class="w-full flex flex-grow" class="w-full flex flex-grow"
:style="{ :style="{
@ -23,6 +25,7 @@ import Form from 'vform'
import {mapState, mapActions} from 'vuex' import {mapState, mapActions} from 'vuex'
import initForm from "../../mixins/form_editor/initForm.js"; import initForm from "../../mixins/form_editor/initForm.js";
import SeoMeta from '../../mixins/seo-meta.js' import SeoMeta from '../../mixins/seo-meta.js'
import CreateFormBaseModal from "../../components/pages/forms/create/CreateFormBaseModal.vue"
const loadTemplates = function () { const loadTemplates = function () {
store.commit('open/templates/startLoading') store.commit('open/templates/startLoading')
@ -35,7 +38,7 @@ export default {
name: 'CreateForm', name: 'CreateForm',
mixins: [initForm, SeoMeta], mixins: [initForm, SeoMeta],
components: {}, components: {CreateFormBaseModal},
beforeRouteEnter(to, from, next) { beforeRouteEnter(to, from, next) {
loadTemplates() loadTemplates()
@ -50,7 +53,8 @@ export default {
stateReady: false, stateReady: false,
loading: false, loading: false,
error: '', error: '',
editorMaxHeight: 500 editorMaxHeight: 500,
showInitialFormModal: false
} }
}, },
@ -92,6 +96,9 @@ export default {
if (template && template.structure) { if (template && template.structure) {
this.form = new Form({...this.form.data(), ...template.structure}) this.form = new Form({...this.form.data(), ...template.structure})
} }
} else {
// No template loaded, ask how to start
this.showInitialFormModal = true
} }
this.closeAlert() this.closeAlert()
this.loadWorkspaces() this.loadWorkspaces()
@ -117,6 +124,9 @@ export default {
if (this.$refs.editor) { if (this.$refs.editor) {
this.editorMaxHeight = window.innerHeight - this.$refs.editor.$el.offsetTop this.editorMaxHeight = window.innerHeight - this.$refs.editor.$el.offsetTop
} }
},
formGenerated(form) {
this.form = new Form({...this.form.data(), ...form})
} }
} }
} }

View File

@ -31,14 +31,14 @@
<h3 class="text-center text-gray-500 mt-8 mb-2">Template Preview</h3> <h3 class="text-center text-gray-500 mt-8 mb-2">Template Preview</h3>
<open-complete-form ref="open-complete-form" :form="form" :creating="true" <open-complete-form ref="open-complete-form" :form="form" :creating="true"
class="mb-4 p-4 bg-gray-50 rounded-lg overflow-hidden"/> class="mb-4 p-4 bg-gray-50 rounded-lg"/>
<div v-if="template.questions.length > 0" id="questions"> <div v-if="template.questions.length > 0" id="questions">
<h3 class="text-xl font-semibold mt-8">Frequently asked questions</h3> <h3 class="text-xl font-semibold mt-8">Frequently asked questions</h3>
<div class="pt-2"> <div class="pt-2">
<div v-for="(ques,ques_key) in template.questions" :key="ques_key" class="my-3 border rounded-lg"> <div v-for="(ques,ques_key) in template.questions" :key="ques_key" class="my-3 border rounded-lg">
<h5 class="border-b p-2">{{ ques.question }}</h5> <h5 class="border-b p-2 text-gray-700 font-semibold">{{ ques.question }}</h5>
<div class="p-2" v-html="ques.answer"></div> <p class="p-2 text-gray-600" v-html="ques.answer"></p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,7 +16,10 @@
>it's free</span>. >it's free</span>.
</h3> </h3>
<div class="mt-6 flex justify-center"> <div class="mt-6 flex justify-center">
<v-button class="mr-1" :to="{ name: 'forms.create.guest' }" :arrow="true"> <v-button v-if="!authenticated" class="mr-1" :to="{ name: 'forms.create.guest' }" :arrow="true">
Create a form for FREE
</v-button>
<v-button v-else class="mr-1" :to="{ name: 'forms.create' }" :arrow="true">
Create a form for FREE Create a form for FREE
</v-button> </v-button>
</div> </div>

View File

@ -13,6 +13,7 @@
'google_analytics_code' => config('services.google_analytics_code'), 'google_analytics_code' => config('services.google_analytics_code'),
'amplitude_code' => config('services.amplitude_code'), 'amplitude_code' => config('services.amplitude_code'),
'crisp_website_id' => config('services.crisp_website_id'), 'crisp_website_id' => config('services.crisp_website_id'),
'ai_features_enabled' => !is_null(config('services.openai.api_key'))
]; ];
@endphp @endphp
<!DOCTYPE html> <!DOCTYPE html>

View File

@ -144,6 +144,9 @@ Route::prefix('forms')->name('forms.')->group(function () {
// File uploads // File uploads
Route::get('assets/{assetFileName}', [PublicFormController::class, 'showAsset'])->name('assets.show'); Route::get('assets/{assetFileName}', [PublicFormController::class, 'showAsset'])->name('assets.show');
// AI
Route::post('ai/generate', [\App\Http\Controllers\Forms\AiFormController::class, 'generateForm'])->name('ai.generate');
}); });
/** /**