mirror of
https://github.com/itflow-org/itflow
synced 2026-08-17 04:55:13 +00:00
Fix AI
This commit is contained in:
@@ -74,6 +74,10 @@ if (isset($_POST['edit_ticket_priority'])) {
|
|||||||
### The `_model.php` pattern
|
### 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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
admin/database_updates/2.6.7.php
Normal file
18
admin/database_updates/2.6.7.php
Normal 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`");
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
178
agent/ajax.php
178
agent/ajax.php
@@ -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
|
||||||
|
|||||||
@@ -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
1
db.sql
@@ -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,
|
||||||
|
|||||||
@@ -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
132
functions/ai.php
Normal 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.";
|
||||||
|
}
|
||||||
37
js/app.js
37
js/app.js
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user