FormOptions = $formOptions; $this->History = new HistoryManager($_history); $prompt = $this->BuildPrompt($message, $files); $response = $this->GetCaller()->Chat($prompt); list($actions, $assistantMessage) = $this->ProcessResponse($response); $this->History->RecordHistoryLog($message, 'user'); return [ "history" => $this->History->GetHistory(), "data" => [ "message" => $assistantMessage, "actions" => $actions ] ]; } /** * Build the prompt text only, without calling the API (external chat mode). */ public function BuildPromptOnly($message = '', $_history = null, $formOptions = null) { $this->FormOptions = $formOptions; $this->History = new HistoryManager($_history); $prompt = $this->BuildPrompt($message, []); $promptText = ''; foreach ($prompt as $msg) { if (isset($msg->role) && $msg->role === 'system') { $promptText .= $msg->content . "\n\n"; } else if (isset($msg->role) && $msg->role === 'user') { $promptText .= "User request: " . $msg->content; } } $promptText = "IMPORTANT: Return ONLY the raw JSON object. Do not add any additional text, explanations, markdown formatting, or code blocks before or after the JSON. Your entire response must be valid JSON and nothing else.\n\n" . $promptText; $this->History->RecordHistoryLog($message, 'user'); return [ "history" => $this->History->GetHistory(), "data" => [ "promptText" => $promptText ] ]; } /** * Process a response pasted from an external chat (external chat mode). */ public function ProcessExternalResponse($responseJson, $message = '', $_history = null, $formOptions = null) { $this->FormOptions = $formOptions; $this->History = new HistoryManager($_history); list($actions, $assistantMessage) = $this->ProcessResponse($responseJson); $this->History->RecordHistoryLog($message, 'user'); return [ "history" => $this->History->GetHistory(), "data" => [ "message" => $assistantMessage, "actions" => $actions ] ]; } /** * Build the system + user prompt messages. */ private function BuildPrompt($message, $files) { $promptFile = AllInOneForms()->GetLoader()->DIR . '/Managers/AIManagers/AIGenerators/AIStyleGenerator/StyleGeneratorPrompt.txt'; $prompt = file_get_contents($promptFile); $prompt = str_replace('{{fields}}', $this->BuildFieldsText(), $prompt); $prompt = str_replace('{{conversationHistory}}', $this->BuildConversationHistory(), $prompt); return [ $this->CreateMessage('system', $prompt), $this->CreateMessageWithFiles('user', $message, $files) ]; } /** * Build the list of the form fields the AI can target. */ private function BuildFieldsText() { $fields = $this->GetForm()->GetFields(true, true); if (empty($fields)) { return "The form has no fields yet."; } $text = ''; foreach ($fields as $field) { $label = isset($field->Options->Label) ? trim((string) $field->Options->Label) : ''; if ($label === '') { $label = '(no label)'; } $text .= '- ID: "' . $field->Options->Id . '", Type: "' . $field->Options->Type . '", Label: "' . $label . '"' . "\r\n"; } return $text; } private function BuildConversationHistory() { $userMessages = $this->History->GetAllMessagesOfRole('user'); $messageHistory = ""; foreach ($userMessages as $userMessage) { if ($messageHistory != "") { $messageHistory .= "\n\n"; } $messageHistory .= $userMessage->content; } if (trim($messageHistory) == "") { $messageHistory = "No conversation history"; } return $messageHistory; } /** * Parse and validate the AI JSON response. * @return array{0: array, 1: string} list of [actions, assistantMessage] * @throws FriendlyException */ private function ProcessResponse($response) { $data = $this->DecodeResponse($response); if ($data == null || !isset($data->actions) || !is_array($data->actions)) { throw new FriendlyException('The AI could not understand the request, please rephrase and try again'); } $validIds = []; $validTypes = []; foreach ($this->GetForm()->GetFields(true, true) as $field) { $validIds[(string) $field->Options->Id] = true; $validTypes[(string) $field->Options->Type] = true; } $actions = []; foreach ($data->actions as $action) { if (isset($action->error) && !empty($action->error)) { throw new FriendlyException($action->error); } $result = $this->ProcessApplyStyleAction($action, $validIds, $validTypes); if ($result !== null) { $actions[] = $result; } } if (count($actions) == 0) { throw new FriendlyException('The AI could not determine any style change, please rephrase and try again'); } $assistantMessage = Sanitizer::GetStringValueFromPath($data, ['message'], 'Done! Here are the style changes.'); return [$actions, $assistantMessage]; } /** * Decode the AI/pasted JSON, tolerating common formatting mistakes * (markdown code fences, surrounding text, and accidentally duplicated * outer braces such as "{{ ... }"). Valid JSON is decoded as-is on the * first attempt and never altered; the repairs only run as a fallback. */ private function DecodeResponse($response) { $data = json_decode($response); if ($data !== null) { return $data; } $text = trim((string) $response); // Remove markdown code fences (```json ... ```). $text = preg_replace('/```(?:json)?/i', '', $text); $text = trim($text); // Keep only the outermost object (drop any text before/after it). $first = strpos($text, '{'); $last = strrpos($text, '}'); if ($first !== false && $last !== false && $last > $first) { $text = substr($text, $first, $last - $first + 1); } $data = json_decode($text); if ($data !== null) { return $data; } // Collapse accidentally duplicated outer braces: "{{ ... }" / "{ ... }}". $repaired = preg_replace('/^\{+/', '{', $text); $repaired = preg_replace('/\}+$/', '}', $repaired); return json_decode($repaired); } private function ProcessApplyStyleAction($action, $validIds, $validTypes) { $scope = strtolower(Sanitizer::GetStringValueFromPath($action, ['scope'], 'field')); $element = strtolower(Sanitizer::GetStringValueFromPath($action, ['element'], 'input')); if (!in_array($scope, self::$AllowedScopes)) { return null; } if (!in_array($element, self::$AllowedElements)) { $element = 'input'; } $targetField = Sanitizer::GetStringValueFromPath($action, ['target_field'], ''); $fieldType = strtolower(Sanitizer::GetStringValueFromPath($action, ['field_type'], '')); if ($scope === 'field') { if ($targetField === '' || !isset($validIds[$targetField])) { return null; } } else if ($scope === 'field_type') { if ($fieldType === '' || !isset($validTypes[$fieldType])) { return null; } } // "form" element is only meaningful with form scope if ($element === 'form' && $scope !== 'form') { $element = 'container'; } $styles = $this->SanitizeStyles(isset($action->styles) ? $action->styles : null); if (empty($styles)) { return null; } return [ 'ActionType' => 'ApplyStyle', 'Data' => (object) [ 'scope' => $scope, 'target_field' => $targetField, 'field_type' => $fieldType, 'element' => $element, 'styles' => (object) $styles ] ]; } /** * Keep only whitelisted CSS properties with safe values. */ private function SanitizeStyles($styles) { $result = []; if ($styles === null) { return $result; } foreach ((array) $styles as $property => $value) { $property = strtolower(trim((string) $property)); if (!in_array($property, self::$AllowedProperties)) { continue; } $value = $this->SanitizeStyleValue($value); if ($value === null) { continue; } $result[$property] = $value; } return $result; } /** * Reject values that could break out of the CSS rule or load external resources. */ private function SanitizeStyleValue($value) { if (!is_string($value) && !is_numeric($value)) { return null; } $value = trim((string) $value); if ($value === '') { return null; } // No characters that could escape the declaration or inject markup. if (preg_match('/[;{}<>\\\\]/', $value)) { return null; } $lowered = strtolower($value); $forbidden = ['url(', 'expression(', '@import', 'javascript:', '/*', '*/']; foreach ($forbidden as $needle) { if (strpos($lowered, $needle) !== false) { return null; } } if (strlen($value) > 300) { return null; } return $value; } public function GetForm() { if ($this->_form == null) { $this->_form = new FormBuilder(AllInOneForms()->GetLoader(), (new BuilderOptionsDTO())->Merge($this->FormOptions)); $this->_form->Initialize(); } return $this->_form; } }