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 @@
+
+
+
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();
+
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',