History = new HistoryManager($_history); if ($this->History->LogIsEmpty()) { $prompt = $this->GetSystemPrompt(); $prompt = [ $this->CreateMessage('system', $prompt) ]; } else { $prompt = $this->History->GetLog(); } $prompt[] = $this->CreateMessageWithFiles('user', $message, $files); $this->History->SetLog($prompt); $message = $this->GetCaller()->Chat($prompt); $this->History->RecordHistoryLog($message); $data = $this->DecodeResponse($message); if ($data == false || !isset($data->html) || !is_string($data->html) || trim($data->html) === '') $data = $this->ExtractFormDataLeniently($message); if ($data == false || !isset($data->html) || !is_string($data->html) || trim($data->html) === '') throw new FriendlyException('The ai responded nonsenses, please try again, it might help if you rephrase your message'); $data->html = $this->SanitizeFormHtml($data->html); return [ "history" => $this->History->GetHistory(), "data" => $data ]; } /** * Build the system prompt with field definitions and special instructions. */ private function GetSystemPrompt() { $prompt = file_get_contents(AllInOneForms()->GetLoader()->DIR . '/Managers/AIManagers/AIGenerators/AIFormGenerator/FormGeneratorPrompt.txt'); $prompt = str_replace("{{fieldDefinitions}}", AIManager::GetFieldDescriptor()->GetBasicFields(), $prompt); $prompt = str_replace("{{SpecialCalculationsInstructions}}", '', $prompt); return $prompt; } /** * Build the prompt text only, without calling the API. * Used for external chat mode. */ public function BuildPromptOnly($message = '', $_history = null) { $this->History = new HistoryManager($_history); if ($this->History->LogIsEmpty()) { $prompt = $this->GetSystemPrompt(); $promptText = $prompt . "\n\n" . "User request: " . $message; } else { // Build prompt text from history + new message $promptText = ''; $log = $this->History->GetLog(); foreach ($log 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 . "\n\n"; } else if (isset($msg->role) && $msg->role === 'assistant') { $promptText .= "Assistant response: " . $msg->content . "\n\n"; } } $promptText .= "User request: " . $message; } // Add JSON-only instruction for external chat $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 an external AI response (pasted JSON). * Used for external chat mode. */ public function ProcessExternalResponse($responseJson, $message = '', $_history = null) { $this->History = new HistoryManager($_history); $data = $this->DecodeResponse($responseJson); if ($data == false || !isset($data->html) || !is_string($data->html) || trim($data->html) === '') $data = $this->ExtractFormDataLeniently($responseJson); if ($data == false || !isset($data->html) || !is_string($data->html) || trim($data->html) === '') throw new FriendlyException('The pasted response is not valid JSON. Please make sure you copied the entire response from the AI chat.'); $data->html = $this->SanitizeFormHtml($data->html); $this->History->RecordHistoryLog($responseJson, 'assistant'); return [ "history" => $this->History->GetHistory(), "data" => $data ]; } /** * Decode the AI/pasted JSON, tolerating markdown fences, surrounding text * and accidentally duplicated outer braces. Valid JSON is decoded as-is. */ private function DecodeResponse($response) { $data = json_decode($response); if ($data !== null) { return $data; } $text = trim((string) $response); $text = preg_replace('/```(?:json)?/i', '', $text); $text = trim($text); $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; } $repaired = preg_replace('/^\{+/', '{', $text); $repaired = preg_replace('/\}+$/', '}', $repaired); return json_decode($repaired); } /** * Recover the form data when the JSON is invalid because the model did not * escape the double quotes inside the `html` value (a very common mistake * with large HTML blobs). Extracts message / form_name / is_multi_steps and * the raw html string directly from the text. */ private function ExtractFormDataLeniently($response) { if (!is_string($response)) { return null; } $text = preg_replace('/```(?:json)?/i', '', trim($response)); $html = null; if (preg_match('/"html"\s*:\s*"/', $text, $m, PREG_OFFSET_CAPTURE)) { // JSON-ish response whose html value has unescaped quotes. The html // value ends right before `,"conditions"` if present, otherwise at the // last double quote of the response (just before the closing `}`). $htmlStart = $m[0][1] + strlen($m[0][0]); // The html value ends right before the next top-level property // (`,"conditions"` or `,"logic_suggestions"`) if present, otherwise // at the last double quote of the response (before the closing `}`). if (preg_match('/"\s*,\s*"(?:conditions|logic_suggestions)"\s*:/', $text, $cm, PREG_OFFSET_CAPTURE, $htmlStart)) { $htmlEnd = $cm[0][1]; } else { $htmlEnd = strrpos($text, '"'); } if ($htmlEnd !== false && $htmlEnd > $htmlStart) { $html = substr($text, $htmlStart, $htmlEnd - $htmlStart); // Undo any JSON escaping the model did apply (raw quotes stay as-is). $html = str_replace(array('\\"', '\\/', '\\\\'), array('"', '/', '\\'), $html); // Decode \uXXXX escapes (incl. emoji surrogate pairs) that json_decode // would normally handle, so emojis don't show up as literal text. $html = $this->DecodeUnicodeEscapes($html); } } else if (preg_match('/<\s*form\b/i', $text) || preg_match('/data-rn-form/i', $text)) { // The user pasted the raw HTML form without the JSON wrapper. $start = stripos($text, ''); if ($endForm !== false) { $html = substr($html, 0, $endForm + strlen('')); } } if ($html === null || trim($html) === '') { return null; } $data = new \stdClass(); $data->html = $html; $data->message = $this->ExtractSimpleJsonString($text, 'message', 'Here is your form, want any changes?'); $data->form_name = $this->ExtractSimpleJsonString($text, 'form_name', 'AI Form'); $data->is_multi_steps = (bool) preg_match('/"is_multi_steps"\s*:\s*true/i', $text) || (bool) preg_match('/data-step\s*=/i', $text); // Recover the structured arrays that live AFTER the html blob. They are // usually valid JSON on their own even when the whole response is not // (the html value is what typically breaks json_decode), so decode each // array independently instead of losing them. $logicSuggestions = $this->ExtractJsonArrayProperty($text, 'logic_suggestions'); if ($logicSuggestions !== null) { $data->logic_suggestions = $logicSuggestions; } $conditions = $this->ExtractJsonArrayProperty($text, 'conditions'); if ($conditions !== null) { $data->conditions = $conditions; } return $data; } /** * Locate a top-level JSON array property (e.g. "logic_suggestions": [ ... ]) * inside a possibly-malformed response, find its balanced closing bracket * (ignoring brackets/quotes inside strings) and json_decode just that slice. * Returns the decoded array, or null when absent/invalid. */ private function ExtractJsonArrayProperty($text, $key) { if (!preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*\[/', $text, $m, PREG_OFFSET_CAPTURE)) { return null; } // Position of the opening bracket of the array. $start = $m[0][1] + strlen($m[0][0]) - 1; $depth = 0; $inString = false; $escaped = false; $length = strlen($text); for ($i = $start; $i < $length; $i++) { $ch = $text[$i]; if ($inString) { if ($escaped) { $escaped = false; } elseif ($ch === '\\') { $escaped = true; } elseif ($ch === '"') { $inString = false; } continue; } if ($ch === '"') { $inString = true; } elseif ($ch === '[') { $depth++; } elseif ($ch === ']') { $depth--; if ($depth === 0) { $arrayText = substr($text, $start, $i - $start + 1); $decoded = json_decode($arrayText); return is_array($decoded) ? $decoded : null; } } } return null; } /** * Decode \uXXXX JSON escapes (including emoji surrogate pairs) into UTF-8, * used when the html was recovered from invalid JSON (so json_decode never ran). */ private function DecodeUnicodeEscapes($text) { if (strpos($text, '\\u') === false) { return $text; } $self = $this; // Surrogate pairs first (e.g. 📧 -> 📧). $text = preg_replace_callback('/\\\\u([dD][89abAB][0-9a-fA-F]{2})\\\\u([dD][c-fC-F][0-9a-fA-F]{2})/', function ($m) use ($self) { $hi = hexdec($m[1]); $lo = hexdec($m[2]); $cp = 0x10000 + (($hi - 0xD800) << 10) + ($lo - 0xDC00); return $self->CodePointToUtf8($cp); }, $text); // Remaining BMP escapes (e.g. ✉, ️). $text = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($m) use ($self) { return $self->CodePointToUtf8(hexdec($m[1])); }, $text); return $text; } public function CodePointToUtf8($codePoint) { if (function_exists('mb_chr')) { return mb_chr($codePoint, 'UTF-8'); } return html_entity_decode('&#' . intval($codePoint) . ';', ENT_QUOTES, 'UTF-8'); } private function ExtractSimpleJsonString($text, $key, $default) { if (preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*"([^"]*)"/', $text, $m)) { return $m[1]; } return $default; } /** * Best-effort server-side sanitization of the form HTML (defense in depth; * the frontend converter is authoritative and only reads structure + * whitelisted styles). Uses regex instead of DOMDocument so that inline SVG * keeps its attribute casing (e.g. viewBox), which a DOM round-trip would * lowercase and break. Removes