From 605c7811e4ae812592614b459b4985a930f4d7da Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 6 Aug 2026 19:18:34 -0400 Subject: [PATCH] Fix AI --- CONTRIBUTING.md | 4 + admin/database_updates/2.6.7.php | 18 +++ admin/modals/ai/ai_model_add.php | 11 ++ admin/modals/ai/ai_model_edit.php | 13 +- admin/post.php | 14 +- admin/post/{ai_model.php => ai_models.php} | 20 ++- agent/ajax.php | 178 +++++++-------------- agent/post.php | 14 +- db.sql | 1 + functions.php | 1 + functions/ai.php | 132 +++++++++++++++ js/app.js | 37 ++++- 12 files changed, 309 insertions(+), 134 deletions(-) create mode 100644 admin/database_updates/2.6.7.php rename admin/post/{ai_model.php => ai_models.php} (60%) create mode 100644 functions/ai.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cf36d651..668c56cc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,10 @@ if (isset($_POST['edit_ticket_priority'])) { ### The `_model.php` pattern Files named `agent/post/*_model.php` hold shared field collection/sanitization logic used by both the create and edit blocks of a module (e.g. `asset_model.php` is included by both `add_asset` and `edit_asset`). If create and edit share more than a couple of fields, use this pattern rather than duplicating. Model files carry the same `FROM_POST_HANDLER` guard and are excluded from the dispatcher's auto-load. + +**`_model.php` is a reserved suffix.** The exclusion is a filename match, so a *handler* named `*_model.php` is silently never loaded — its form posts, nothing claims the request, and the user gets a blank page with no error anywhere. This is what happened to `admin/post/ai_model.php`, which is why the AI Models handler is now `admin/post/ai_models.php`. Name entity handlers around the suffix (`ai_models.php`, `users.php`, `api_keys.php`). + +A POST that reaches the end of `admin/post.php` or `agent/post.php` without a handler claiming it is logged to App Logs as a `Request` warning, which is the fastest way to spot this class of mistake. --- diff --git a/admin/database_updates/2.6.7.php b/admin/database_updates/2.6.7.php new file mode 100644 index 000000000..b56a4114f --- /dev/null +++ b/admin/database_updates/2.6.7.php @@ -0,0 +1,18 @@ + +
+ +
+
+ +
+ +
+ Optional. Leave blank to let the provider use its default - some newer models reject every other value. +
+
diff --git a/admin/modals/ai/ai_model_edit.php b/admin/modals/ai/ai_model_edit.php index 6f780bf03..05413b98a 100644 --- a/admin/modals/ai/ai_model_edit.php +++ b/admin/modals/ai/ai_model_edit.php @@ -4,13 +4,14 @@ require_once '../../includes/modal_header.php'; $model_id = intval($_GET['id']); -$sql = mysqli_query($mysqli, "SELECT ai_model_ai_provider_id, ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case FROM ai_models WHERE ai_model_id = $model_id LIMIT 1"); +$sql = mysqli_query($mysqli, "SELECT ai_model_ai_provider_id, ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_model_temperature FROM ai_models WHERE ai_model_id = $model_id LIMIT 1"); $row = mysqli_fetch_assoc($sql); $ai_model_ai_provider_id = intval($row['ai_model_ai_provider_id']); $model_id = intval($row['ai_model_id']); $model_name = escapeHtml($row['ai_model_name']); $use_case = escapeHtml($row['ai_model_use_case']); +$temperature = escapeHtml($row['ai_model_temperature']); $prompt = escapeHtml($row['ai_model_prompt']); // Generate the HTML form content using output buffering. @@ -74,6 +75,16 @@ ob_start(); +
+ +
+
+ +
+ +
+ Optional. Leave blank to let the provider use its default - some newer models reject every other value. +
diff --git a/admin/post.php b/admin/post.php index 272b8564e..6802e4bfb 100644 --- a/admin/post.php +++ b/admin/post.php @@ -10,7 +10,11 @@ require_once __DIR__ . "/../includes/check_login.php"; // Only allow running post files via inclusion (prevents people/bots poking them directly) define('FROM_POST_HANDLER', true); -// Load all admin module POST logic +// Load all admin module POST logic. +// *_model.php is a RESERVED suffix: those files are not handlers, they are inline +// field-parsing fragments that read $_POST at include time, so the glob must not +// pull them in. A handler named *_model.php is silently never loaded - name entity +// handlers around it (admin/post/ai_models.php, not ai_model.php). if (!empty($session_is_admin)) { foreach (glob(__DIR__ . "/post/*.php") as $admin_module) { if (!str_ends_with($admin_module, '_model.php')) { @@ -22,3 +26,11 @@ if (!empty($session_is_admin)) { // Logout is shared between portals require_once __DIR__ . "/../post/logout.php"; require_once __DIR__ . "/../post/misc.php"; + +// Every handler above exits or redirects, so getting here means no handler claimed +// the request - a blank page and no trace of why. Log it; the usual cause is a +// handler file that never loaded. +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $unhandled = implode(', ', array_slice(array_keys($_POST), 0, 10)); + logApp('Request', 'warning', "Unhandled POST to admin/post.php - no handler matched. Fields: $unhandled"); +} diff --git a/admin/post/ai_model.php b/admin/post/ai_models.php similarity index 60% rename from admin/post/ai_model.php rename to admin/post/ai_models.php index f6ceaddce..cf927ff51 100644 --- a/admin/post/ai_model.php +++ b/admin/post/ai_models.php @@ -1,7 +1,7 @@ $model could not be created - see Admin > App Logs", 'error'); + redirect(); + } logAudit("AI Model", "Create", "$session_name created AI Model $model"); @@ -36,7 +44,11 @@ if (isset($_POST['edit_ai_model'])) { $prompt = escapeSql($_POST['prompt']); $use_case = escapeSql($_POST['use_case']); - mysqli_query($mysqli,"UPDATE ai_models SET ai_model_name = '$model', ai_model_prompt = '$prompt', ai_model_use_case = '$use_case' WHERE ai_model_id = $model_id"); + // Blank means "send no temperature at all" - the only setting that works on every + // provider. Anything else rides as a numeric literal, so no quoting. + $temperature = ($_POST['temperature'] ?? '') === '' ? 'NULL' : floatval($_POST['temperature']); + + mysqli_query($mysqli,"UPDATE ai_models SET ai_model_name = '$model', ai_model_prompt = '$prompt', ai_model_use_case = '$use_case', ai_model_temperature = $temperature WHERE ai_model_id = $model_id"); logAudit("AI Model", "Edit", "$session_name edited AI Model $model"); diff --git a/agent/ajax.php b/agent/ajax.php index b3d961fb9..ee8746a82 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -837,127 +837,88 @@ if (isset($_GET['ai_reword'])) { header('Content-Type: application/json'); - $sql = mysqli_query($mysqli, "SELECT ai_model_name, ai_model_prompt, ai_provider_api_key, ai_provider_api_url FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id WHERE ai_model_use_case = 'General' LIMIT 1"); + // The reword button sits on every TinyMCE instance, so the ticket editor asks for + // the Tickets model and everything else gets General. Anything unrecognised is + // treated as General rather than trusted into the query. + $use_case = ($_GET['use_case'] ?? '') === 'Tickets' ? 'Tickets' : 'General'; - $row = mysqli_fetch_assoc($sql); - $model_name = $row['ai_model_name']; - $promptText = $row['ai_model_prompt']; - $url = $row['ai_provider_api_url']; - $key = $row['ai_provider_api_key']; + $model = getAiModel($use_case); + + if (!$model) { + echo json_encode(['error' => aiModelMissingError($use_case)]); + exit; + } // Collecting the input data from the AJAX request. $inputJSON = file_get_contents('php://input'); $input = json_decode($inputJSON, TRUE); // Convert JSON into array. - $userText = $input['text']; + $userText = $input['text'] ?? ''; - // Preparing the data for the OpenAI Chat API request. - $data = [ - "model" => "$model_name", // Specify the model - "messages" => [ - ["role" => "system", "content" => $promptText], - ["role" => "user", "content" => $userText], - ], - "temperature" => 0.5 - ]; - - // Initialize cURL session to the OpenAI Chat API. - $ch = curl_init("$url"); - - // Set cURL options for the request. - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $key, + $result = callAiApi($model, [ + ["role" => "system", "content" => $model['ai_model_prompt']], + ["role" => "user", "content" => $userText], ]); - // Execute the cURL session and capture the response. - $response = curl_exec($ch); - curl_close($ch); - - // Decode the JSON response. - $responseData = json_decode($response, true); - - // Check if the response contains the expected data and return it. - if (isset($responseData['choices'][0]['message']['content'])) { - // Get the response content. - $content = $responseData['choices'][0]['message']['content']; - - // Clean any leading "html" word or other unwanted text at the beginning. - $content = preg_replace('/^html/i', '', $content); // Remove any occurrence of 'html' at the start - - // Clean the response content to remove backticks or code block markers. - $cleanedContent = str_replace('```', '', $content); // Remove backticks if they exist. - - // Trim any leading/trailing whitespace. - $cleanedContent = trim($cleanedContent); - - // Return the cleaned response. - echo json_encode(['rewordedText' => $cleanedContent]); - } else { - // Handle errors or unexpected response structure. - echo json_encode(['rewordedText' => 'Failed to get a response from the AI API.']); + // Report failures as an error, never as reworded text - the editor writes + // rewordedText straight back over the user's content + if (!$result['ok']) { + echo json_encode(['error' => $result['error']]); + exit; } + $content = $result['content']; + + // Clean any leading "html" word or other unwanted text at the beginning. + $content = preg_replace('/^html/i', '', $content); // Remove any occurrence of 'html' at the start + + // Clean the response content to remove backticks or code block markers. + $cleanedContent = str_replace('```', '', $content); // Remove backticks if they exist. + + // Trim any leading/trailing whitespace. + $cleanedContent = trim($cleanedContent); + + echo json_encode(['rewordedText' => $cleanedContent]); + } if (isset($_GET['ai_create_document_template'])) { - // get_ai_document_template.php + + enforceUserPermission('module_support'); header('Content-Type: text/html; charset=UTF-8'); - $sql = mysqli_query($mysqli, "SELECT ai_model_name, ai_provider_api_key, ai_provider_api_url FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id WHERE ai_model_use_case = 'General' LIMIT 1"); - - $row = mysqli_fetch_assoc($sql); - $model_name = $row['ai_model_name']; - $url = $row['ai_provider_api_url']; - $key = $row['ai_provider_api_key']; - $prompt = $_POST['prompt'] ?? ''; // Basic validation - if(empty($prompt)){ + if (empty($prompt)) { echo "No prompt provided."; exit; } + $model = getAiModel('Documentation'); + + if (!$model) { + echo escapeHtml(aiModelMissingError('Documentation')); + exit; + } + // Prepare prompt $system_message = "You are a helpful IT documentation assistant. You will create a well-structured HTML template for IT documentation based on a given prompt. Include headings, subheadings, bullet points, and possibly tables for clarity. No Lorem Ipsum, use realistic placeholders and professional language."; $user_message = "Create an HTML formatted IT documentation template based on the following request:\n\n\"$prompt\"\n\nThe template should be structured, professional, and useful for IT staff. Include relevant sections, instructions, prerequisites, and best practices."; - $post_data = [ - "model" => "$model_name", - "messages" => [ - ["role" => "system", "content" => $system_message], - ["role" => "user", "content" => $user_message] - ], - "temperature" => 0.5 - ]; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $key + $result = callAiApi($model, [ + ["role" => "system", "content" => $system_message], + ["role" => "user", "content" => $user_message] ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data)); - $response = curl_exec($ch); - if (curl_errno($ch)) { - echo "Error: " . curl_error($ch); + if (!$result['ok']) { + echo "

" . escapeHtml($result['error']) . "

"; exit; } - curl_close($ch); - - $response_data = json_decode($response, true); - $template = $response_data['choices'][0]['message']['content'] ?? "

No content returned from AI.

"; // Print the generated HTML template directly - echo $template; + echo $result['content']; } if (isset($_GET['ai_ticket_summary'])) { @@ -966,12 +927,12 @@ if (isset($_GET['ai_ticket_summary'])) { header('Content-Type: text/html; charset=UTF-8'); - $sql = mysqli_query($mysqli, "SELECT ai_model_name, ai_provider_api_key, ai_provider_api_url FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id WHERE ai_model_use_case = 'General' LIMIT 1"); + $model = getAiModel('Tickets'); - $row = mysqli_fetch_assoc($sql); - $model_name = $row['ai_model_name']; - $url = $row['ai_provider_api_url']; - $key = $row['ai_provider_api_key']; + if (!$model) { + echo escapeHtml(aiModelMissingError('Tickets')); + exit; + } // Retrieve the ticket_id from POST $ticket_id = intval($_POST['ticket_id']); @@ -1048,38 +1009,17 @@ if (isset($_GET['ai_ticket_summary'])) { If any part of the ticket or replies is unclear or ambiguous, mention it in the summary and suggest if further clarification is needed. "; - // Prepare the POST data - $post_data = [ - "model" => "$model_name", - "messages" => [ - ["role" => "system", "content" => "Your task is to summarize IT support tickets with clear, concise details."], - ["role" => "user", "content" => $prompt] - ], - "temperature" => 0.3 - ]; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Bearer ' . $key + $result = callAiApi($model, [ + ["role" => "system", "content" => "Your task is to summarize IT support tickets with clear, concise details."], + ["role" => "user", "content" => $prompt] ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data)); - $response = curl_exec($ch); - if (curl_errno($ch)) { - echo "Error: " . curl_error($ch); + if (!$result['ok']) { + echo "

" . escapeHtml($result['error']) . "

"; exit; } - curl_close($ch); - $response_data = json_decode($response, true); - $summary = $response_data['choices'][0]['message']['content'] ?? "No summary available."; - - - echo $summary; // nl2br to convert newlines to
, htmlspecialchars to prevent XSS + echo $result['content']; } // Stops people trying to use sub-domains in the domains tracker diff --git a/agent/post.php b/agent/post.php index 9c498ba67..810918239 100644 --- a/agent/post.php +++ b/agent/post.php @@ -10,8 +10,12 @@ require_once __DIR__ . "/../includes/check_login.php"; // Only allow running post files via inclusion (prevents people/bots poking them directly) define('FROM_POST_HANDLER', true); -// Load all agent module POST logic +// Load all agent module POST logic. // TODO: selectively load per-module like admin does, keyed off request path (not referer) +// *_model.php is a RESERVED suffix: those files are not handlers, they are inline +// field-parsing fragments that read $_POST at include time, so the glob must not +// pull them in. A handler named *_model.php is silently never loaded - name entity +// handlers around it (admin/post/ai_models.php, not ai_model.php). foreach (glob(__DIR__ . "/post/*.php") as $user_module) { if (!str_ends_with($user_module, '_model.php')) { require_once $user_module; @@ -21,3 +25,11 @@ foreach (glob(__DIR__ . "/post/*.php") as $user_module) { // Logout is shared between portals require_once __DIR__ . "/../post/logout.php"; require_once __DIR__ . "/../post/misc.php"; + +// Every handler above exits or redirects, so getting here means no handler claimed +// the request - a blank page and no trace of why. Log it; the usual cause is a +// handler file that never loaded. +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $unhandled = implode(', ', array_slice(array_keys($_POST), 0, 10)); + logApp('Request', 'warning', "Unhandled POST to agent/post.php - no handler matched. Fields: $unhandled"); +} diff --git a/db.sql b/db.sql index c0bcebd73..e039a630c 100644 --- a/db.sql +++ b/db.sql @@ -50,6 +50,7 @@ CREATE TABLE `ai_models` ( `ai_model_name` varchar(200) NOT NULL, `ai_model_prompt` text DEFAULT NULL, `ai_model_use_case` varchar(200) DEFAULT NULL, + `ai_model_temperature` decimal(3,2) DEFAULT NULL, `ai_model_created_at` datetime NOT NULL DEFAULT current_timestamp(), `ai_model_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), `ai_model_ai_provider_id` int(11) NOT NULL, diff --git a/functions.php b/functions.php index ff70057de..fbea6e270 100644 --- a/functions.php +++ b/functions.php @@ -22,6 +22,7 @@ require_once __DIR__ . '/functions/logging.php'; require_once __DIR__ . '/functions/app.php'; require_once __DIR__ . '/functions/payments.php'; require_once __DIR__ . '/functions/sla.php'; +require_once __DIR__ . '/functions/ai.php'; require_once __DIR__ . '/functions/export.php'; require_once __DIR__ . '/functions/calendar.php'; require_once __DIR__ . '/functions/backup.php'; diff --git a/functions/ai.php b/functions/ai.php new file mode 100644 index 000000000..76e88e94f --- /dev/null +++ b/functions/ai.php @@ -0,0 +1,132 @@ + true, 'content' => '...'] + * ['ok' => false, 'error' => 'short message safe to show the user'] + * + * Provider detail - status code, error type, code and message - goes to the app log + * so a misconfiguration is diagnosable. The API key and the message bodies never do. + */ +function callAiApi($model, $messages) { + + $data = [ + 'model' => $model['ai_model_name'], + 'messages' => $messages, + ]; + + // Only send a temperature when the model has one configured. Newer OpenAI models + // accept nothing but their own default and 400 on anything else, which is what + // the old hardcoded 0.5 / 0.3 ran into. + if (isset($model['ai_model_temperature']) && $model['ai_model_temperature'] !== '') { + $data['temperature'] = floatval($model['ai_model_temperature']); + } + + $ch = curl_init($model['ai_provider_api_url']); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_TIMEOUT, AI_REQUEST_TIMEOUT); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $model['ai_provider_api_key'], + ]); + + $response = curl_exec($ch); + $status = intval(curl_getinfo($ch, CURLINFO_RESPONSE_CODE)); + $transport_error = curl_error($ch); + curl_close($ch); + + // Enough to tell one provider/model pairing from another in the log + $context = $model['ai_provider_name'] . ' / ' . $model['ai_model_name']; + + if ($response === false) { + logApp('AI', 'error', "$context - could not reach the provider: $transport_error"); + return ['ok' => false, 'error' => 'Could not reach the AI provider.']; + } + + $decoded = json_decode($response, true); + + if ($status < 200 || $status > 299) { + $provider_error = $decoded['error'] ?? []; + $detail = "$context - HTTP $status"; + foreach (['type', 'code', 'param'] as $field) { + if (!empty($provider_error[$field])) { + $detail .= " $field=" . $provider_error[$field]; + } + } + if (!empty($provider_error['message'])) { + $detail .= ' - ' . $provider_error['message']; + } + logApp('AI', 'error', $detail); + return ['ok' => false, 'error' => 'The AI provider rejected the request - see Admin > App Logs.']; + } + + if (!isset($decoded['choices'][0]['message']['content'])) { + logApp('AI', 'error', "$context - HTTP $status but the response carried no choices[0].message.content"); + return ['ok' => false, 'error' => 'The AI provider returned an unexpected response - see Admin > App Logs.']; + } + + return ['ok' => true, 'content' => $decoded['choices'][0]['message']['content']]; +} + +/* + * What to say when nothing is configured for a use case. Logged as well as shown, + * because "no model" and "model rejected the request" look identical from the UI. + */ +function aiModelMissingError($use_case) { + logApp('AI', 'warning', "No AI model configured for use case '$use_case' and no General model to fall back on"); + return "No AI model is configured for $use_case. Add one under Admin > AI Models."; +} diff --git a/js/app.js b/js/app.js index 0b988a571..e1b08476e 100644 --- a/js/app.js +++ b/js/app.js @@ -145,13 +145,23 @@ $(document).ready(function() { return response.json(); }) .then(data => { - editor.undoManager.transact(function() { - editor.setContent(data.rewordedText || 'Error: Could not reword the text.'); - }); - editor.setProgressState(false); rewordButtonApi.setEnabled(true); + // Leave the user's text alone if the reword failed + if (data.error || !data.rewordedText) { + editor.notificationManager.open({ + text: data.error || 'Could not reword the text.', + type: 'error', + timeout: 8000 + }); + return; + } + + editor.undoManager.transact(function() { + editor.setContent(data.rewordedText); + }); + editor.notificationManager.open({ text: 'Text reworded successfully!', type: 'success', @@ -245,7 +255,7 @@ $(document).ready(function() { rewordButtonApi.setEnabled(false); editor.setProgressState(true); - fetch('ajax.php?ai_reword', { + fetch('ajax.php?ai_reword&use_case=Tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: content }), @@ -255,11 +265,22 @@ $(document).ready(function() { return response.json(); }) .then(data => { - editor.undoManager.transact(function() { - editor.setContent(data.rewordedText || 'Error: Could not reword the text.'); - }); editor.setProgressState(false); rewordButtonApi.setEnabled(true); + + // Leave the user's text alone if the reword failed + if (data.error || !data.rewordedText) { + editor.notificationManager.open({ + text: data.error || 'Could not reword the text.', + type: 'error', + timeout: 8000 + }); + return; + } + + editor.undoManager.transact(function() { + editor.setContent(data.rewordedText); + }); editor.notificationManager.open({ text: 'Text reworded successfully!', type: 'success',