This commit is contained in:
johnnyq
2026-08-06 19:18:34 -04:00
parent 942fe41b18
commit 605c7811e4
12 changed files with 309 additions and 134 deletions

View File

@@ -75,6 +75,10 @@ if (isset($_POST['edit_ticket_priority'])) {
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. 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.
--- ---
## Cron ## Cron

View File

@@ -0,0 +1,18 @@
<?php
/*
* ITFlow - Database update to version 2.6.7 (from 2.6.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// The AI endpoints used to send a hardcoded temperature (0.5, or 0.3 for ticket
// summaries). Newer OpenAI models accept nothing but their own default and reject
// the request outright, which surfaced as "Failed to get a response from the AI API".
//
// Temperature is now per-model and optional: NULL means don't send the parameter
// at all, which is the setting that works on every provider. Existing rows get
// NULL so they stop sending it.
mysqli_query($mysqli, "ALTER TABLE `ai_models` ADD COLUMN IF NOT EXISTS `ai_model_temperature` decimal(3,2) DEFAULT NULL AFTER `ai_model_use_case`");

View File

@@ -62,6 +62,17 @@ ob_start();
</div> </div>
</div> </div>
<div class="form-group">
<label>Temperature</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
</div>
<input type="number" class="form-control" name="temperature" step="0.1" min="0" max="2" value="" placeholder="Provider default">
</div>
<small class="form-text text-muted">Optional. Leave blank to let the provider use its default - some newer models reject every other value.</small>
</div>
<div class="form-group"> <div class="form-group">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"></textarea> <textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"></textarea>
</div> </div>

View File

@@ -4,13 +4,14 @@ require_once '../../includes/modal_header.php';
$model_id = intval($_GET['id']); $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); $row = mysqli_fetch_assoc($sql);
$ai_model_ai_provider_id = intval($row['ai_model_ai_provider_id']); $ai_model_ai_provider_id = intval($row['ai_model_ai_provider_id']);
$model_id = intval($row['ai_model_id']); $model_id = intval($row['ai_model_id']);
$model_name = escapeHtml($row['ai_model_name']); $model_name = escapeHtml($row['ai_model_name']);
$use_case = escapeHtml($row['ai_model_use_case']); $use_case = escapeHtml($row['ai_model_use_case']);
$temperature = escapeHtml($row['ai_model_temperature']);
$prompt = escapeHtml($row['ai_model_prompt']); $prompt = escapeHtml($row['ai_model_prompt']);
// Generate the HTML form content using output buffering. // Generate the HTML form content using output buffering.
@@ -74,6 +75,16 @@ ob_start();
</div> </div>
</div> </div>
<div class="form-group">
<label>Temperature</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
</div>
<input type="number" class="form-control" name="temperature" step="0.1" min="0" max="2" value="<?= $temperature ?>" placeholder="Provider default">
</div>
<small class="form-text text-muted">Optional. Leave blank to let the provider use its default - some newer models reject every other value.</small>
</div>
<div class="form-group"> <div class="form-group">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"><?= $prompt ?></textarea> <textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"><?= $prompt ?></textarea>
</div> </div>

View File

@@ -10,7 +10,11 @@ require_once __DIR__ . "/../includes/check_login.php";
// Only allow running post files via inclusion (prevents people/bots poking them directly) // Only allow running post files via inclusion (prevents people/bots poking them directly)
define('FROM_POST_HANDLER', true); 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)) { if (!empty($session_is_admin)) {
foreach (glob(__DIR__ . "/post/*.php") as $admin_module) { foreach (glob(__DIR__ . "/post/*.php") as $admin_module) {
if (!str_ends_with($admin_module, '_model.php')) { if (!str_ends_with($admin_module, '_model.php')) {
@@ -22,3 +26,11 @@ if (!empty($session_is_admin)) {
// Logout is shared between portals // Logout is shared between portals
require_once __DIR__ . "/../post/logout.php"; require_once __DIR__ . "/../post/logout.php";
require_once __DIR__ . "/../post/misc.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");
}

View File

@@ -1,7 +1,7 @@
<?php <?php
/* /*
* ITFlow - GET/POST request handler for AI Models ('ai_model') * ITFlow - GET/POST request handler for AI Models ('ai_models')
*/ */
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
@@ -15,9 +15,17 @@ if (isset($_POST['add_ai_model'])) {
$prompt = escapeSql($_POST['prompt']); $prompt = escapeSql($_POST['prompt']);
$use_case = escapeSql($_POST['use_case']); $use_case = escapeSql($_POST['use_case']);
mysqli_query($mysqli,"INSERT INTO ai_models SET ai_model_name = '$model', ai_model_prompt = '$prompt', ai_model_use_case = '$use_case', ai_model_ai_provider_id = $provider_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']);
$ai_model_id = mysqli_insert_id($mysqli); mysqli_query($mysqli,"INSERT INTO ai_models SET ai_model_name = '$model', ai_model_prompt = '$prompt', ai_model_use_case = '$use_case', ai_model_temperature = $temperature, ai_model_ai_provider_id = $provider_id");
if (!mysqli_affected_rows($mysqli)) {
logApp('AI', 'error', 'Failed to create AI Model ' . $model . ': ' . mysqli_error($mysqli));
flashAlert("AI Model <strong>$model</strong> could not be created - see Admin > App Logs", 'error');
redirect();
}
logAudit("AI Model", "Create", "$session_name created AI Model $model"); logAudit("AI Model", "Create", "$session_name created AI Model $model");
@@ -36,7 +44,11 @@ if (isset($_POST['edit_ai_model'])) {
$prompt = escapeSql($_POST['prompt']); $prompt = escapeSql($_POST['prompt']);
$use_case = escapeSql($_POST['use_case']); $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"); logAudit("AI Model", "Edit", "$session_name edited AI Model $model");

View File

@@ -837,127 +837,88 @@ if (isset($_GET['ai_reword'])) {
header('Content-Type: application/json'); 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 = getAiModel($use_case);
$model_name = $row['ai_model_name'];
$promptText = $row['ai_model_prompt']; if (!$model) {
$url = $row['ai_provider_api_url']; echo json_encode(['error' => aiModelMissingError($use_case)]);
$key = $row['ai_provider_api_key']; exit;
}
// Collecting the input data from the AJAX request. // Collecting the input data from the AJAX request.
$inputJSON = file_get_contents('php://input'); $inputJSON = file_get_contents('php://input');
$input = json_decode($inputJSON, TRUE); // Convert JSON into array. $input = json_decode($inputJSON, TRUE); // Convert JSON into array.
$userText = $input['text']; $userText = $input['text'] ?? '';
// Preparing the data for the OpenAI Chat API request. $result = callAiApi($model, [
$data = [ ["role" => "system", "content" => $model['ai_model_prompt']],
"model" => "$model_name", // Specify the model ["role" => "user", "content" => $userText],
"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,
]); ]);
// Execute the cURL session and capture the response. // Report failures as an error, never as reworded text - the editor writes
$response = curl_exec($ch); // rewordedText straight back over the user's content
curl_close($ch); if (!$result['ok']) {
echo json_encode(['error' => $result['error']]);
// Decode the JSON response. exit;
$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.']);
} }
$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'])) { if (isset($_GET['ai_create_document_template'])) {
// get_ai_document_template.php
enforceUserPermission('module_support');
header('Content-Type: text/html; charset=UTF-8'); 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'] ?? ''; $prompt = $_POST['prompt'] ?? '';
// Basic validation // Basic validation
if(empty($prompt)){ if (empty($prompt)) {
echo "No prompt provided."; echo "No prompt provided.";
exit; exit;
} }
$model = getAiModel('Documentation');
if (!$model) {
echo escapeHtml(aiModelMissingError('Documentation'));
exit;
}
// Prepare prompt // 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."; $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."; $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 = [ $result = callAiApi($model, [
"model" => "$model_name", ["role" => "system", "content" => $system_message],
"messages" => [ ["role" => "user", "content" => $user_message]
["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
]); ]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
$response = curl_exec($ch); if (!$result['ok']) {
if (curl_errno($ch)) { echo "<p>" . escapeHtml($result['error']) . "</p>";
echo "Error: " . curl_error($ch);
exit; exit;
} }
curl_close($ch);
$response_data = json_decode($response, true);
$template = $response_data['choices'][0]['message']['content'] ?? "<p>No content returned from AI.</p>";
// Print the generated HTML template directly // Print the generated HTML template directly
echo $template; echo $result['content'];
} }
if (isset($_GET['ai_ticket_summary'])) { if (isset($_GET['ai_ticket_summary'])) {
@@ -966,12 +927,12 @@ if (isset($_GET['ai_ticket_summary'])) {
header('Content-Type: text/html; charset=UTF-8'); 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); if (!$model) {
$model_name = $row['ai_model_name']; echo escapeHtml(aiModelMissingError('Tickets'));
$url = $row['ai_provider_api_url']; exit;
$key = $row['ai_provider_api_key']; }
// Retrieve the ticket_id from POST // Retrieve the ticket_id from POST
$ticket_id = intval($_POST['ticket_id']); $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. 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 $result = callAiApi($model, [
$post_data = [ ["role" => "system", "content" => "Your task is to summarize IT support tickets with clear, concise details."],
"model" => "$model_name", ["role" => "user", "content" => $prompt]
"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
]); ]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
$response = curl_exec($ch); if (!$result['ok']) {
if (curl_errno($ch)) { echo "<p>" . escapeHtml($result['error']) . "</p>";
echo "Error: " . curl_error($ch);
exit; exit;
} }
curl_close($ch);
$response_data = json_decode($response, true); echo $result['content'];
$summary = $response_data['choices'][0]['message']['content'] ?? "No summary available.";
echo $summary; // nl2br to convert newlines to <br>, htmlspecialchars to prevent XSS
} }
// Stops people trying to use sub-domains in the domains tracker // Stops people trying to use sub-domains in the domains tracker

View File

@@ -10,8 +10,12 @@ require_once __DIR__ . "/../includes/check_login.php";
// Only allow running post files via inclusion (prevents people/bots poking them directly) // Only allow running post files via inclusion (prevents people/bots poking them directly)
define('FROM_POST_HANDLER', true); 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) // 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) { foreach (glob(__DIR__ . "/post/*.php") as $user_module) {
if (!str_ends_with($user_module, '_model.php')) { if (!str_ends_with($user_module, '_model.php')) {
require_once $user_module; require_once $user_module;
@@ -21,3 +25,11 @@ foreach (glob(__DIR__ . "/post/*.php") as $user_module) {
// Logout is shared between portals // Logout is shared between portals
require_once __DIR__ . "/../post/logout.php"; require_once __DIR__ . "/../post/logout.php";
require_once __DIR__ . "/../post/misc.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");
}

1
db.sql
View File

@@ -50,6 +50,7 @@ CREATE TABLE `ai_models` (
`ai_model_name` varchar(200) NOT NULL, `ai_model_name` varchar(200) NOT NULL,
`ai_model_prompt` text DEFAULT NULL, `ai_model_prompt` text DEFAULT NULL,
`ai_model_use_case` varchar(200) 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_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`ai_model_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), `ai_model_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`ai_model_ai_provider_id` int(11) NOT NULL, `ai_model_ai_provider_id` int(11) NOT NULL,

View File

@@ -22,6 +22,7 @@ require_once __DIR__ . '/functions/logging.php';
require_once __DIR__ . '/functions/app.php'; require_once __DIR__ . '/functions/app.php';
require_once __DIR__ . '/functions/payments.php'; require_once __DIR__ . '/functions/payments.php';
require_once __DIR__ . '/functions/sla.php'; require_once __DIR__ . '/functions/sla.php';
require_once __DIR__ . '/functions/ai.php';
require_once __DIR__ . '/functions/export.php'; require_once __DIR__ . '/functions/export.php';
require_once __DIR__ . '/functions/calendar.php'; require_once __DIR__ . '/functions/calendar.php';
require_once __DIR__ . '/functions/backup.php'; require_once __DIR__ . '/functions/backup.php';

132
functions/ai.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
/*
* ITFlow - AI helpers
*
* The three AI endpoints in agent/ajax.php each carried their own copy of the model
* lookup and the cURL boilerplate, which is how they all ended up hardcoding
* use_case = 'General' and a temperature the provider may not accept. Model
* selection and the provider call live here instead.
*/
// A provider that never answers would otherwise hold a PHP worker open until
// max_execution_time
DEFINE("AI_REQUEST_TIMEOUT", 60);
/*
* The model configured for a use case - one of the values the add/edit modals offer:
* General, Tickets, Documentation.
*
* A feature-specific model wins, a General model is the fallback, so an install with
* a single General model keeps working everywhere. Returns null when nothing usable
* is configured; callers report that rather than posting to an empty URL.
*/
function getAiModel($use_case = 'General') {
global $mysqli;
$use_case = escapeSql($use_case);
// Feature-specific first, then General - FIELD() keeps that preference in SQL so
// one query answers both
$preference = ($use_case === 'General') ? "'General'" : "'$use_case', 'General'";
$sql = mysqli_query($mysqli,
"SELECT ai_model_name, ai_model_prompt, ai_model_use_case, ai_model_temperature,
ai_provider_name, ai_provider_api_url, ai_provider_api_key
FROM ai_models
LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id
WHERE ai_model_use_case IN ($preference)
ORDER BY FIELD(ai_model_use_case, $preference), ai_model_id ASC
LIMIT 1"
);
$model = mysqli_fetch_assoc($sql);
// A model row with no provider behind it (or no endpoint) can't be called
if (!$model || empty($model['ai_model_name']) || empty($model['ai_provider_api_url'])) {
return null;
}
return $model;
}
/*
* Posts a chat-completion request. Returns:
*
* ['ok' => 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.";
}

View File

@@ -145,13 +145,23 @@ $(document).ready(function() {
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
});
editor.setProgressState(false); editor.setProgressState(false);
rewordButtonApi.setEnabled(true); 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({ editor.notificationManager.open({
text: 'Text reworded successfully!', text: 'Text reworded successfully!',
type: 'success', type: 'success',
@@ -245,7 +255,7 @@ $(document).ready(function() {
rewordButtonApi.setEnabled(false); rewordButtonApi.setEnabled(false);
editor.setProgressState(true); editor.setProgressState(true);
fetch('ajax.php?ai_reword', { fetch('ajax.php?ai_reword&use_case=Tickets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }), body: JSON.stringify({ text: content }),
@@ -255,11 +265,22 @@ $(document).ready(function() {
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
editor.undoManager.transact(function() {
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
});
editor.setProgressState(false); editor.setProgressState(false);
rewordButtonApi.setEnabled(true); 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({ editor.notificationManager.open({
text: 'Text reworded successfully!', text: 'Text reworded successfully!',
type: 'success', type: 'success',