mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
920 lines
47 KiB
PHP
920 lines
47 KiB
PHP
<?php
|
|
|
|
/*
|
|
* ITFlow - AI
|
|
*
|
|
* The AI providers set up in Admin > AI Providers - their kind (OpenAI, Azure,
|
|
* a model run on your own network...) decides how each is called - the models
|
|
* on them, each for a use case with General the fallback, and every request
|
|
* made to one: who may make it (the role's Use AI permission), the passwords
|
|
* and keys taken out of it first and put back in the answer (aiRedact()), and
|
|
* what it cost (ai_usage - Admin > AI Usage).
|
|
*
|
|
* The features themselves - Reword, the ticket summary, a document template
|
|
* draft - are the endpoints in agent/ajax.php, each naming itself
|
|
* (aiFeatures()) so its requests can be told apart.
|
|
*/
|
|
|
|
// A provider that never answers would otherwise hold a PHP worker open until max_execution_time
|
|
DEFINE("AI_REQUEST_TIMEOUT", 60);
|
|
|
|
/*
|
|
* The kinds of provider, ready made - name, chat completions address, whether
|
|
* it needs a key, how the key is sent (bearer, or Azure's api-key header),
|
|
* whether and how it lists its models, whether it runs on your own network (so
|
|
* nothing leaves it), where its keys are made and a model to start with.
|
|
* 'custom' is any other OpenAI-compatible endpoint.
|
|
*/
|
|
function aiProviderKinds() {
|
|
return [
|
|
'openai' => ['name' => 'OpenAI', 'url' => 'https://api.openai.com/v1/chat/completions', 'key' => true, 'auth' => 'bearer', 'models' => 'openai', 'local' => false,
|
|
'keys_url' => 'https://platform.openai.com/api-keys', 'example' => 'gpt-5-mini'],
|
|
'anthropic' => ['name' => 'Anthropic (Claude)', 'url' => 'https://api.anthropic.com/v1/chat/completions', 'key' => true, 'auth' => 'bearer', 'models' => 'anthropic', 'local' => false,
|
|
'keys_url' => 'https://console.anthropic.com/settings/keys', 'example' => 'claude-sonnet-5-5'],
|
|
'azure' => ['name' => 'Azure OpenAI', 'url' => 'https://YOUR-RESOURCE.openai.azure.com/openai/v1/chat/completions', 'key' => true, 'auth' => 'api-key', 'models' => '', 'local' => false,
|
|
'keys_url' => 'https://portal.azure.com', 'example' => 'your deployment name'],
|
|
'gemini' => ['name' => 'Google Gemini', 'url' => 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', 'key' => true, 'auth' => 'bearer', 'models' => 'openai', 'local' => false,
|
|
'keys_url' => 'https://aistudio.google.com/apikey', 'example' => 'gemini-2.5-flash'],
|
|
'mistral' => ['name' => 'Mistral', 'url' => 'https://api.mistral.ai/v1/chat/completions', 'key' => true, 'auth' => 'bearer', 'models' => 'openai', 'local' => false,
|
|
'keys_url' => 'https://console.mistral.ai/api-keys', 'example' => 'mistral-small-latest'],
|
|
'groq' => ['name' => 'Groq', 'url' => 'https://api.groq.com/openai/v1/chat/completions', 'key' => true, 'auth' => 'bearer', 'models' => 'openai', 'local' => false,
|
|
'keys_url' => 'https://console.groq.com/keys', 'example' => 'llama-3.3-70b-versatile'],
|
|
'openrouter' => ['name' => 'OpenRouter', 'url' => 'https://openrouter.ai/api/v1/chat/completions', 'key' => true, 'auth' => 'bearer', 'models' => 'openai', 'local' => false,
|
|
'keys_url' => 'https://openrouter.ai/keys', 'example' => 'openai/gpt-5-mini'],
|
|
'ollama' => ['name' => 'Ollama', 'url' => 'http://localhost:11434/v1/chat/completions', 'key' => false, 'auth' => 'bearer', 'models' => 'openai', 'local' => true,
|
|
'keys_url' => '', 'example' => 'llama3.1'],
|
|
'lmstudio' => ['name' => 'LM Studio', 'url' => 'http://localhost:1234/v1/chat/completions', 'key' => false, 'auth' => 'bearer', 'models' => 'openai', 'local' => true,
|
|
'keys_url' => '', 'example' => 'the model loaded in LM Studio'],
|
|
'custom' => ['name' => 'Other (OpenAI-compatible)', 'url' => '', 'key' => false, 'auth' => 'bearer', 'models' => 'openai', 'local' => false,
|
|
'keys_url' => '', 'example' => 'its model name'],
|
|
];
|
|
}
|
|
|
|
/* A provider's kind, as aiProviderKinds() describes it - custom for one it doesn't know. */
|
|
function aiProviderKind($kind) {
|
|
$kinds = aiProviderKinds();
|
|
return $kinds[(string) $kind] ?? $kinds['custom'];
|
|
}
|
|
|
|
/* What a model can be for, and the features each covers. A use case with no model of its own uses General. */
|
|
function aiUseCases() {
|
|
return [
|
|
'General' => 'Everything without a model of its own - Reword and translate in any editor, and client briefings.',
|
|
'Tickets' => 'Drafting replies, triage, summaries, and Reword and translate in the ticket reply editor.',
|
|
'Documentation' => 'Drafting and reviewing documents, writing a ticket up as a document, and drafting document templates.',
|
|
'Billing' => 'Writing quotes and invoices - their lines from your catalog, labor at the client\'s rate - and the work done on a ticket for its invoice line.',
|
|
];
|
|
}
|
|
|
|
/* Each AI feature - its name as Admin > AI Usage shows it. */
|
|
function aiFeatures() {
|
|
return [
|
|
'reword' => 'Reword',
|
|
'reword_ticket' => 'Reword a ticket reply',
|
|
'translate' => 'Translate',
|
|
'ticket_draft' => 'Draft a reply',
|
|
'ticket_triage' => 'Triage',
|
|
'ticket_summary' => 'Ticket summary',
|
|
'document_template' => 'Document template draft',
|
|
'ticket_document' => 'Ticket written up as a document',
|
|
'document_draft' => 'Document draft',
|
|
'document_review' => 'Document review',
|
|
'client_briefing' => 'Client briefing',
|
|
'billing_draft' => 'Quote or invoice written',
|
|
'invoice_description' => 'Ticket time described for its invoice',
|
|
'test' => 'Test',
|
|
];
|
|
}
|
|
|
|
/* Whether the signed-in agent may use AI - their role's Use AI permission (admins always). */
|
|
function aiCan() {
|
|
return (bool) lookupUserPermission('module_ai');
|
|
}
|
|
|
|
/*
|
|
* The model configured for a use case - one of aiUseCases(). A feature-specific
|
|
* model wins, a General model is the fallback, so an install with a single
|
|
* General model works everywhere. Null when nothing usable is configured;
|
|
* callers report that rather than posting to an empty URL.
|
|
*/
|
|
function getAiModel($use_case = 'General') {
|
|
global $mysqli;
|
|
static $models = [];
|
|
$use_case = isset(aiUseCases()[$use_case]) ? $use_case : 'General';
|
|
if (array_key_exists($use_case, $models)) {
|
|
return $models[$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'";
|
|
|
|
// Before the 3.3.8 update, every provider is called as an OpenAI-compatible one, secrets taken out
|
|
$provider_columns = aiUsageTableExists() ? 'ai_provider_kind, ai_provider_redact' : "'custom' AS ai_provider_kind, 1 AS ai_provider_redact";
|
|
|
|
$model = mysqli_fetch_assoc(mysqli_query($mysqli,
|
|
"SELECT ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_model_temperature,
|
|
ai_provider_id, ai_provider_name, $provider_columns, ai_provider_api_url, ai_provider_api_key
|
|
FROM ai_models
|
|
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"));
|
|
|
|
// A model with no name, or a provider with no address, can't be called
|
|
if (!$model || trim((string) $model['ai_model_name']) === '' || trim((string) $model['ai_provider_api_url']) === '') {
|
|
$model = null;
|
|
}
|
|
return $models[$use_case] = $model;
|
|
}
|
|
|
|
/* Whether AI is there for this agent, for a use case - set up, and their role may use it. */
|
|
function aiReady($use_case = 'General') {
|
|
return aiCan() && getAiModel($use_case) !== null;
|
|
}
|
|
|
|
/*
|
|
* What the editors' script needs to know (js/app.js reads window.itflowAi) -
|
|
* whether Reword is there in a ticket reply and elsewhere, and where to ask.
|
|
* False when it is in neither - then the editors have no Reword button.
|
|
*/
|
|
function aiClientConfig() {
|
|
if (empty($_SESSION['logged']) || !aiCan()) {
|
|
return false;
|
|
}
|
|
$general = getAiModel('General') !== null;
|
|
$tickets = getAiModel('Tickets') !== null;
|
|
if (!$general && !$tickets) {
|
|
return false;
|
|
}
|
|
// Draft is on the ticket reply editor only - its textarea says which ticket
|
|
return ['reword' => $general, 'rewordTicket' => $tickets, 'draft' => $tickets, 'endpoint' => '/agent/ajax.php',
|
|
'tones' => aiRewordTones(), 'languages' => aiLanguages()];
|
|
}
|
|
|
|
/* What Reword can do to text - tone => what the editor's menu calls it. Reword is the button itself; Translate asks which language. */
|
|
function aiRewordTones() {
|
|
return [
|
|
'reword' => 'Reword - clearer and more professional',
|
|
'shorter' => 'Make it shorter',
|
|
'formal' => 'More formal',
|
|
'friendly' => 'Friendlier',
|
|
'plain' => 'Plain English, for someone non-technical',
|
|
'structure' => 'Organize it - headings and lists',
|
|
'proofread' => 'Just fix spelling and grammar',
|
|
'translate' => 'Translate...',
|
|
];
|
|
}
|
|
|
|
/* The languages Translate offers. */
|
|
function aiLanguages() {
|
|
return ['English', 'Spanish', 'French', 'German', 'Portuguese', 'Italian', 'Dutch', 'Polish', 'Swedish', 'Danish', 'Norwegian', 'Finnish', 'Czech', 'Romanian',
|
|
'Hungarian', 'Greek', 'Turkish', 'Russian', 'Ukrainian', 'Arabic', 'Hebrew', 'Hindi', 'Vietnamese', 'Thai', 'Indonesian', 'Filipino', 'Chinese (Simplified)',
|
|
'Chinese (Traditional)', 'Japanese', 'Korean'];
|
|
}
|
|
|
|
/*
|
|
* What Reword asks of the model, for a tone (aiRewordTones()) - in a ticket
|
|
* reply ($use_case 'Tickets') or anywhere else - and the language for
|
|
* Translate. Every tone keeps the facts, names, numbers, links, commands and
|
|
* placeholders, and the HTML formatting.
|
|
*/
|
|
function aiRewordInstructions($use_case, $tone = 'reword', $language = '') {
|
|
$what = $use_case === 'Tickets' ? 'the replies an IT services company sends on support tickets' : 'what an IT services company writes';
|
|
$asks = [
|
|
'reword' => $use_case === 'Tickets'
|
|
? 'Rewrite the reply you are given so it reads clearly, professionally and kindly - fix spelling and grammar, and make it easy for someone who isn\'t technical to follow.'
|
|
: 'Rewrite the text you are given so it reads clearly and professionally - fix spelling and grammar, and tighten it.',
|
|
'shorter' => 'Rewrite the text you are given shorter - about half as long - keeping everything that matters and dropping repetition and filler.',
|
|
'formal' => 'Rewrite the text you are given in a more formal, businesslike tone, still clear and plain.',
|
|
'friendly' => 'Rewrite the text you are given in a warmer, friendlier tone, still professional - no exclamation marks every sentence.',
|
|
'plain' => 'Rewrite the text you are given in plain English for someone who isn\'t technical: short sentences, everyday words, and a few words to explain any technical term that has to stay.',
|
|
'structure' => 'Organize the text you are given: put it under short headings, turn steps into numbered lists and other runs of items into bullet lists, and split long paragraphs - changing as few words as you can.',
|
|
'proofread' => 'Fix only the spelling, grammar and punctuation of the text you are given. Change nothing else - not the wording, the tone or the order.',
|
|
'translate' => 'Translate the text you are given into ' . ($language !== '' ? $language : 'English') . ', as a native speaker in IT support would write it. Leave product names, commands, file paths, error messages quoted from software, and placeholders in square brackets as they are.',
|
|
];
|
|
$instructions = "You edit $what. " . ($asks[$tone] ?? $asks['reword']);
|
|
$instructions .= ' Keep its meaning and every fact, name, number, date, link, command and placeholder in square brackets exactly as they are.'
|
|
. ($tone === 'translate' ? '' : ' Write in the language it is written in.')
|
|
. ' Don\'t add anything it doesn\'t say. Keep its HTML formatting - paragraphs, line breaks, lists, bold and links' . ($tone === 'structure' ? ', adding the headings and lists asked for' : '')
|
|
. ' - and answer with the HTML only: no introduction, no notes, no code fences.';
|
|
return $instructions;
|
|
}
|
|
|
|
/*
|
|
* 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 set up for $use_case. Add one in Admin > AI Providers.";
|
|
}
|
|
|
|
/* ------------------------------------------------------------- Keeping secrets out */
|
|
|
|
/*
|
|
* Passwords, keys and the like taken out of text before it goes to a provider:
|
|
* [the text with each replaced by [secret-1], [secret-2]..., the placeholders =>
|
|
* what they stand for]. The same secret twice gets the same placeholder. What
|
|
* comes back is put right with aiRestore(), so the provider never sees them and
|
|
* the answer still has them. Works on plain text and on an editor's HTML.
|
|
*/
|
|
function aiRedact($text, array &$secrets = []) {
|
|
$text = (string) $text;
|
|
$keep = function ($secret) use (&$secrets) {
|
|
$found = array_search($secret, $secrets, true);
|
|
if ($found !== false) {
|
|
return $found;
|
|
}
|
|
$placeholder = '[secret-' . (count($secrets) + 1) . ']';
|
|
$secrets[$placeholder] = $secret;
|
|
return $placeholder;
|
|
};
|
|
$whole = function ($m) use ($keep) {
|
|
return $keep($m[0]);
|
|
};
|
|
|
|
// Things that are secret whole - private keys, 2FA setup links, the keys services hand out
|
|
foreach ([
|
|
'/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/s',
|
|
'#otpauth://[^\s<>"\']+#i',
|
|
'/\b(?:sk|rk|pk)_live_[A-Za-z0-9]{10,}\b/',
|
|
'/\b(?:sk|rk)_test_[A-Za-z0-9]{10,}\b/',
|
|
'/\bsk-(?:proj-|ant-[a-z0-9]*-?|or-v1-)?[A-Za-z0-9_-]{20,}/',
|
|
'/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/',
|
|
'/\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})\b/',
|
|
'/\bglpat-[A-Za-z0-9_-]{20,}/',
|
|
'/\bxox[abprs]-[A-Za-z0-9-]{10,}/',
|
|
'/\bAIza[0-9A-Za-z_-]{35}\b/',
|
|
'/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/',
|
|
] as $pattern) {
|
|
$text = preg_replace_callback($pattern, $whole, $text);
|
|
}
|
|
|
|
// What follows "Password:", "PIN =", "API key:" and the like - the label stays.
|
|
// An editor's tags may sit around the colon (<strong>Password:</strong> ...).
|
|
$gap = '(?:\s| |<\/?[a-z][^>]*>)*';
|
|
$value = '((?:&(?!nbsp;)|[^\s<>"\'&\x{201C}\x{201D}]){3,})';
|
|
$tidy = function ($m) use ($keep) {
|
|
$secret = $m[2];
|
|
if (strpos($secret, '[secret-') === 0) {
|
|
return $m[0];
|
|
}
|
|
// A full stop or comma after it ends the sentence, not the password
|
|
$after = '';
|
|
if (preg_match('/^(.{3,}?)([.,;)]+)$/su', $secret, $split)) {
|
|
[$secret, $after] = [$split[1], $split[2]];
|
|
}
|
|
return $m[1] . $keep($secret) . $after;
|
|
};
|
|
$labels = 'password|passwd|pwd|passphrase|passcode|pin|secret|client[ _-]?secret|api[ _-]?key|access[ _-]?key|secret[ _-]?key|token|otp|(?:2fa|mfa|totp) code|recovery codes?|wi-?fi key|psk';
|
|
// In quotes, spaces and all - Password: "correct horse battery"
|
|
$text = preg_replace_callback("/(\\b(?:$labels)\\b{$gap}[:=]{$gap})([\"'\\x{201C}])([^\"'\\x{201D}<>\\n]{1,200})([\"'\\x{201D}])/iu", function ($m) use ($keep) {
|
|
return $m[1] . $m[2] . $keep($m[3]) . $m[4];
|
|
}, $text);
|
|
$text = preg_replace_callback("/(\\b(?:$labels)\\b{$gap}[:=]{$gap}[\"'\\x{201C}]?)$value/iu", $tidy, $text);
|
|
// "The password is Summer2024!" - only when it looks like one, so "the password is wrong" stays
|
|
$text = preg_replace_callback("/(\\b(?:password|passcode|passphrase|pin)\\s+(?:is|was|will be)\\s+{$gap}[\"'\\x{201C}]?)(?=[^\\s<>\"']*[0-9!@#\$%^*_+=?~-])$value/iu", $tidy, $text);
|
|
|
|
// Card numbers - 13 to 19 digits that pass the Luhn check
|
|
$text = preg_replace_callback('/\b\d(?:[ -]?\d){12,18}\b/', function ($m) use ($keep) {
|
|
$digits = preg_replace('/\D/', '', $m[0]);
|
|
$sum = 0;
|
|
foreach (array_reverse(str_split($digits)) as $i => $digit) {
|
|
$digit = intval($digit);
|
|
if ($i % 2) {
|
|
$digit *= 2;
|
|
$digit = $digit > 9 ? $digit - 9 : $digit;
|
|
}
|
|
$sum += $digit;
|
|
}
|
|
return $sum % 10 === 0 ? $keep($m[0]) : $m[0];
|
|
}, $text);
|
|
|
|
return [$text, $secrets];
|
|
}
|
|
|
|
/* An answer with the secrets aiRedact() took out put back where it left them. */
|
|
function aiRestore($text, array $secrets) {
|
|
return $secrets ? strtr((string) $text, $secrets) : (string) $text;
|
|
}
|
|
|
|
/* ------------------------------------------------------------- Calling a provider */
|
|
|
|
/* The headers a provider is sent - its key, as its kind takes it. */
|
|
function aiProviderHeaders($kind, $api_key, $json = true) {
|
|
$headers = $json ? ['Content-Type: application/json'] : [];
|
|
$api_key = trim((string) $api_key);
|
|
if ($api_key !== '') {
|
|
$headers[] = aiProviderKind($kind)['auth'] === 'api-key' ? "api-key: $api_key" : "Authorization: Bearer $api_key";
|
|
}
|
|
return $headers;
|
|
}
|
|
|
|
/*
|
|
* Posts a chat-completion request for a feature (aiFeatures() - $context
|
|
* 'feature', and 'client_id' when it is about a client's things). Returns:
|
|
*
|
|
* ['ok' => true, 'content' => '...']
|
|
* ['ok' => false, 'error' => 'short message safe to show the user']
|
|
*
|
|
* The first message is the feature's own instructions (a system message). The
|
|
* model's instructions from Admin > AI Providers follow them. Passwords and keys
|
|
* are taken out first when the provider is set to, and put back in the answer -
|
|
* or, with $context 'secrets' => 'remove' (for what is kept, like a document),
|
|
* left out as [removed]. Every request is recorded in ai_usage. Provider detail on a failure - status,
|
|
* 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, array $context = []) {
|
|
$messages = array_values($messages);
|
|
if (($messages[0]['role'] ?? '') !== 'system') {
|
|
array_unshift($messages, ['role' => 'system', 'content' => 'You are a helpful assistant for an IT services company.']);
|
|
}
|
|
|
|
$secrets = [];
|
|
if (!empty($model['ai_provider_redact'])) {
|
|
foreach ($messages as $i => $message) {
|
|
if ($message['role'] !== 'system') {
|
|
[$messages[$i]['content']] = aiRedact($message['content'], $secrets);
|
|
}
|
|
}
|
|
if ($secrets) {
|
|
$messages[0]['content'] .= "\n\nSome passwords and keys have been replaced with placeholders like [secret-1]. Keep each placeholder exactly as it is wherever it belongs in your answer, and don't remark on them.";
|
|
}
|
|
}
|
|
|
|
// The company's own instructions for the model - house style, things to always or never say
|
|
$instructions = trim((string) ($model['ai_model_prompt'] ?? ''));
|
|
if ($instructions !== '') {
|
|
array_splice($messages, 1, 0, [['role' => 'system', 'content' => "Also follow these instructions from the company:\n$instructions"]]);
|
|
}
|
|
|
|
$data = ['model' => $model['ai_model_name'], 'messages' => $messages];
|
|
|
|
// Only send a temperature when the model has one configured. Newer models
|
|
// accept nothing but their own default and 400 on anything else.
|
|
if (isset($model['ai_model_temperature']) && $model['ai_model_temperature'] !== '') {
|
|
$data['temperature'] = floatval($model['ai_model_temperature']);
|
|
}
|
|
|
|
$started = microtime(true);
|
|
$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, aiProviderHeaders($model['ai_provider_kind'] ?? 'custom', $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
|
|
$label = $model['ai_provider_name'] . ' / ' . $model['ai_model_name'];
|
|
$usage = [
|
|
'feature' => (string) ($context['feature'] ?? 'other'),
|
|
'model' => $model,
|
|
'client_id' => intval($context['client_id'] ?? 0),
|
|
'ms' => intval(round((microtime(true) - $started) * 1000)),
|
|
'redacted' => count($secrets),
|
|
];
|
|
|
|
if ($response === false) {
|
|
logApp('AI', 'error', "$label - could not reach the provider: $transport_error");
|
|
aiUsageRecord($usage + ['ok' => false, 'error' => 'Could not reach the provider']);
|
|
return ['ok' => false, 'error' => 'Could not reach the AI provider.'];
|
|
}
|
|
|
|
$decoded = json_decode($response, true);
|
|
|
|
if ($status < 200 || $status > 299) {
|
|
$provider_error = $decoded['error'] ?? [];
|
|
$provider_error = is_array($provider_error) ? $provider_error : ['message' => (string) $provider_error];
|
|
$detail = "$label - HTTP $status";
|
|
foreach (['type', 'code', 'param'] as $field) {
|
|
if (!empty($provider_error[$field]) && is_scalar($provider_error[$field])) {
|
|
$detail .= " $field=" . $provider_error[$field];
|
|
}
|
|
}
|
|
if (!empty($provider_error['message']) && is_scalar($provider_error['message'])) {
|
|
$detail .= ' - ' . $provider_error['message'];
|
|
}
|
|
logApp('AI', 'error', $detail);
|
|
aiUsageRecord($usage + ['ok' => false, 'error' => "HTTP $status" . (!empty($provider_error['message']) && is_scalar($provider_error['message']) ? ' - ' . $provider_error['message'] : '')]);
|
|
return ['ok' => false, 'error' => "The AI provider turned the request away (HTTP $status) - see Admin > App Logs."];
|
|
}
|
|
|
|
$content = $decoded['choices'][0]['message']['content'] ?? null;
|
|
if (!is_string($content)) {
|
|
logApp('AI', 'error', "$label - HTTP $status but the response carried no choices[0].message.content");
|
|
aiUsageRecord($usage + ['ok' => false, 'error' => 'No answer in the response']);
|
|
return ['ok' => false, 'error' => 'The AI provider returned an unexpected response - see Admin > App Logs.'];
|
|
}
|
|
|
|
aiUsageRecord($usage + ['ok' => true, 'tokens_in' => intval($decoded['usage']['prompt_tokens'] ?? 0), 'tokens_out' => intval($decoded['usage']['completion_tokens'] ?? 0)]);
|
|
|
|
// Put back - or, for what is kept (a document, an invoice line), left out for good; a placeholder the model made up as well
|
|
if (($context['secrets'] ?? 'restore') === 'remove') {
|
|
return ['ok' => true, 'content' => preg_replace('/\[secret-\d+\]/', '[removed]', aiRestore($content, array_fill_keys(array_keys($secrets), '[removed]'))), 'removed' => count($secrets)];
|
|
}
|
|
return ['ok' => true, 'content' => aiRestore($content, $secrets)];
|
|
}
|
|
|
|
/*
|
|
* An answer that should be HTML, as safe HTML - without the code fences or a
|
|
* leading "html" models like to add, and through the purifier the document
|
|
* pages use, so nothing in a ticket can make it carry a script.
|
|
*/
|
|
function aiCleanHtml($content) {
|
|
$content = trim((string) $content);
|
|
$content = preg_replace('/^```[a-z]*\s*/i', '', $content);
|
|
$content = preg_replace('/\s*```$/', '', $content);
|
|
$content = preg_replace('/^html\b\s*/i', '', $content);
|
|
return trim(documentHtmlPurify(str_replace('```', '', $content)));
|
|
}
|
|
|
|
/* ------------------------------------------------------------- Usage */
|
|
|
|
/*
|
|
* Whether the 3.3.8 update has run - the providers' kind and redact columns,
|
|
* and ai_usage. Until it has (new files, database not yet updated), AI works
|
|
* as before and nothing is recorded. The database version says, where it is
|
|
* known; otherwise the table and column are looked for.
|
|
*/
|
|
function aiUsageTableExists() {
|
|
global $mysqli;
|
|
static $exists = null;
|
|
if ($exists === null) {
|
|
if (defined('CURRENT_DATABASE_VERSION') && version_compare(CURRENT_DATABASE_VERSION, '3.3.8', '>=')) {
|
|
$exists = true;
|
|
} else {
|
|
// Both what it adds - the table, and the providers' kind
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
|
|
AND ((TABLE_NAME = 'ai_usage' AND COLUMN_NAME = 'ai_usage_id') OR (TABLE_NAME = 'ai_providers' AND COLUMN_NAME = 'ai_provider_kind'))"));
|
|
$exists = intval($row[0] ?? 0) === 2;
|
|
}
|
|
}
|
|
return $exists;
|
|
}
|
|
|
|
/* Record one request - its feature, model (the row callAiApi() was given), client, tokens, time, how many secrets were taken out, and whether it worked. */
|
|
function aiUsageRecord(array $usage) {
|
|
global $mysqli, $session_user_id;
|
|
if (!aiUsageTableExists()) {
|
|
return;
|
|
}
|
|
$model = $usage['model'] ?? [];
|
|
$text = function ($value, $length) {
|
|
return "'" . mysqli_real_escape_string($GLOBALS['mysqli'], mb_substr((string) $value, 0, $length)) . "'";
|
|
};
|
|
mysqli_query($mysqli, "INSERT INTO ai_usage SET
|
|
ai_usage_feature = " . $text($usage['feature'] ?? 'other', 50) . ",
|
|
ai_usage_use_case = " . $text($model['ai_model_use_case'] ?? '', 50) . ",
|
|
ai_usage_provider_id = " . intval($model['ai_provider_id'] ?? 0) . ",
|
|
ai_usage_provider_name = " . $text($model['ai_provider_name'] ?? '', 200) . ",
|
|
ai_usage_model_id = " . intval($model['ai_model_id'] ?? 0) . ",
|
|
ai_usage_model = " . $text($model['ai_model_name'] ?? '', 200) . ",
|
|
ai_usage_user_id = " . intval($session_user_id ?? 0) . ",
|
|
ai_usage_client_id = " . intval($usage['client_id'] ?? 0) . ",
|
|
ai_usage_tokens_in = " . intval($usage['tokens_in'] ?? 0) . ",
|
|
ai_usage_tokens_out = " . intval($usage['tokens_out'] ?? 0) . ",
|
|
ai_usage_ms = " . intval($usage['ms'] ?? 0) . ",
|
|
ai_usage_redacted = " . intval($usage['redacted'] ?? 0) . ",
|
|
ai_usage_ok = " . (!empty($usage['ok']) ? 1 : 0) . ",
|
|
ai_usage_error = " . (!empty($usage['error']) ? $text($usage['error'], 250) : 'NULL'));
|
|
}
|
|
|
|
/*
|
|
* The last $days by provider or model ($by 'provider' or 'model') - id =>
|
|
* requests, failed, tokens in and out, and when it was last used. Tests aren't counted.
|
|
*/
|
|
function aiUsageTotals($by = 'provider', $days = 30) {
|
|
global $mysqli;
|
|
if (!aiUsageTableExists()) {
|
|
return [];
|
|
}
|
|
$column = $by === 'model' ? 'ai_usage_model_id' : 'ai_usage_provider_id';
|
|
$totals = [];
|
|
$sql = mysqli_query($mysqli, "SELECT $column, COUNT(*), SUM(ai_usage_ok = 0), SUM(ai_usage_tokens_in), SUM(ai_usage_tokens_out), MAX(ai_usage_created_at)
|
|
FROM ai_usage WHERE ai_usage_created_at >= NOW() - INTERVAL " . intval($days) . " DAY AND ai_usage_feature <> 'test' GROUP BY $column");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
$totals[intval($row[0])] = ['requests' => intval($row[1]), 'failed' => intval($row[2]), 'tokens_in' => intval($row[3]), 'tokens_out' => intval($row[4]), 'last' => $row[5]];
|
|
}
|
|
return $totals;
|
|
}
|
|
|
|
/* A number of tokens as it reads - 950, 12.4k, 3.1M. */
|
|
function aiTokensText($tokens) {
|
|
$tokens = intval($tokens);
|
|
foreach ([1000000 => 'M', 1000 => 'k'] as $size => $suffix) {
|
|
if ($tokens >= $size) {
|
|
return rtrim(rtrim(number_format($tokens / $size, 1, '.', ''), '0'), '.') . $suffix;
|
|
}
|
|
}
|
|
return (string) $tokens;
|
|
}
|
|
|
|
/* ------------------------------------------------------------- Testing a provider */
|
|
|
|
/* Where a provider lists its models - worked out from its chat completions address. '' when it doesn't list them. */
|
|
function aiModelsUrl($kind, $url) {
|
|
$listing = aiProviderKind($kind)['models'];
|
|
if ($listing === 'anthropic') {
|
|
return 'https://api.anthropic.com/v1/models?limit=1000';
|
|
}
|
|
$path = strtok((string) $url, '?');
|
|
if ($listing === '' || !preg_match('#/chat/completions/?$#', (string) $path)) {
|
|
return '';
|
|
}
|
|
return preg_replace('#/chat/completions/?$#', '/models', $path);
|
|
}
|
|
|
|
/*
|
|
* Ask a provider which models it has - which says whether its address and key
|
|
* work. ['ok' => bool, 'models' => their names, 'error' => what went wrong in
|
|
* words, 'listed' => false when it doesn't list them (Azure) - then it isn't asked].
|
|
*/
|
|
function aiProviderTest($kind, $url, $api_key) {
|
|
$models_url = aiModelsUrl($kind, $url);
|
|
if ($models_url === '') {
|
|
return ['ok' => true, 'models' => [], 'error' => '', 'listed' => false];
|
|
}
|
|
$headers = aiProviderHeaders($kind, $api_key, false);
|
|
if (aiProviderKind($kind)['models'] === 'anthropic') {
|
|
$headers = ['x-api-key: ' . trim((string) $api_key), 'anthropic-version: 2023-06-01'];
|
|
}
|
|
$ch = curl_init($models_url);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
$response = curl_exec($ch);
|
|
$status = intval(curl_getinfo($ch, CURLINFO_RESPONSE_CODE));
|
|
$transport_error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($response === false) {
|
|
return ['ok' => false, 'models' => [], 'error' => "It couldn't be reached - $transport_error.", 'listed' => true];
|
|
}
|
|
$decoded = json_decode($response, true);
|
|
if ($status === 401 || $status === 403) {
|
|
return ['ok' => false, 'models' => [], 'error' => "It turned the key away (HTTP $status) - check it is right and still active.", 'listed' => true];
|
|
}
|
|
if ($status < 200 || $status > 299 || !is_array($decoded)) {
|
|
$message = is_array($decoded['error'] ?? null) ? (string) ($decoded['error']['message'] ?? '') : (is_string($decoded['error'] ?? null) ? $decoded['error'] : '');
|
|
return ['ok' => false, 'models' => [], 'error' => "It answered HTTP $status" . ($message !== '' ? " - " . mb_substr($message, 0, 200) : '') . ' - check the address.', 'listed' => true];
|
|
}
|
|
$models = [];
|
|
foreach ((array) ($decoded['data'] ?? $decoded['models'] ?? []) as $item) {
|
|
$id = is_array($item) ? (string) ($item['id'] ?? $item['name'] ?? '') : (string) $item;
|
|
if ($id !== '') {
|
|
$models[] = preg_replace('#^models/#', '', $id);
|
|
}
|
|
}
|
|
$models = array_values(array_unique($models));
|
|
sort($models, SORT_NATURAL | SORT_FLAG_CASE);
|
|
return ['ok' => true, 'models' => $models, 'error' => '', 'listed' => true];
|
|
}
|
|
|
|
/* ------------------------------------------------------------- Admin > AI Providers */
|
|
|
|
/* A provider - with how many models it has - or null. */
|
|
function aiProviderRow($provider_id) {
|
|
global $mysqli;
|
|
$provider_id = intval($provider_id);
|
|
if ($provider_id <= 0) {
|
|
return null;
|
|
}
|
|
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ai_providers.*, (SELECT COUNT(*) FROM ai_models WHERE ai_model_ai_provider_id = ai_provider_id) AS model_count
|
|
FROM ai_providers WHERE ai_provider_id = $provider_id")) ?: null;
|
|
}
|
|
|
|
/* A model, with its provider's name and kind - or null. */
|
|
function aiModelRow($model_id) {
|
|
global $mysqli;
|
|
$model_id = intval($model_id);
|
|
if ($model_id <= 0) {
|
|
return null;
|
|
}
|
|
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ai_models.*, ai_provider_name, ai_provider_kind FROM ai_models
|
|
LEFT JOIN ai_providers ON ai_provider_id = ai_model_ai_provider_id WHERE ai_model_id = $model_id")) ?: null;
|
|
}
|
|
|
|
/*
|
|
* A provider's address as it is called - its chat completions address. One
|
|
* pasted as the base (https://api.example.com/v1) gets /chat/completions put
|
|
* on, as the OpenAI libraries would; an Azure resource on its own gets the
|
|
* rest of the path too.
|
|
*/
|
|
function aiProviderUrl($kind, $url) {
|
|
$url = trim((string) $url);
|
|
if ($url === '' || !preg_match('#^https?://#i', $url)) {
|
|
return $url;
|
|
}
|
|
$path = (string) parse_url($url, PHP_URL_PATH);
|
|
if ($kind === 'azure' && trim($path, '/') === '') {
|
|
return rtrim($url, '/') . '/openai/v1/chat/completions';
|
|
}
|
|
if (preg_match('#/v\d+(?:beta\d*)?(?:/openai)?/?$#i', $path)) {
|
|
return rtrim($url, '/') . '/chat/completions';
|
|
}
|
|
return $url;
|
|
}
|
|
|
|
/*
|
|
* A provider as sent from its window, checked - [the row to save (raw values),
|
|
* field => what is wrong]. The key isn't put back into the page, so left empty
|
|
* on an existing provider it stays; 'clear_key' takes it away.
|
|
*/
|
|
function aiProviderCheck(array $post, $before = null) {
|
|
$kinds = aiProviderKinds();
|
|
$errors = [];
|
|
$kind = (string) ($post['kind'] ?? ($before['ai_provider_kind'] ?? 'custom'));
|
|
if (!isset($kinds[$kind])) {
|
|
$errors['kind'] = 'Pick what kind of provider it is.';
|
|
$kind = 'custom';
|
|
}
|
|
$info = $kinds[$kind];
|
|
|
|
$name = trim(preg_replace('/\s+/', ' ', (string) ($post['name'] ?? '')));
|
|
if ($name === '') {
|
|
$errors['name'] = 'Give it a name - ' . $info['name'] . ', or what you call it.';
|
|
} elseif (mb_strlen($name) > 200) {
|
|
$errors['name'] = 'That name is longer than 200 characters.';
|
|
} else {
|
|
global $mysqli;
|
|
$taken = mysqli_fetch_row(mysqli_query($mysqli, "SELECT ai_provider_id FROM ai_providers WHERE ai_provider_name = '" . mysqli_real_escape_string($mysqli, $name) . "'
|
|
AND ai_provider_id <> " . intval($before['ai_provider_id'] ?? 0) . " LIMIT 1"));
|
|
if ($taken) {
|
|
$errors['name'] = 'There is a provider called that already - give this one a name of its own.';
|
|
}
|
|
}
|
|
|
|
$url = aiProviderUrl($kind, $post['url'] ?? '');
|
|
if ($url === '') {
|
|
$errors['url'] = 'Its address is needed - where ITFlow sends requests.';
|
|
} elseif (mb_strlen($url) > 500) {
|
|
$errors['url'] = 'That address is longer than 500 characters.';
|
|
} elseif (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
|
|
$errors['url'] = "That isn't a web address - it starts https:// (or http:// for one on your own network).";
|
|
} elseif (stripos($url, 'YOUR-RESOURCE') !== false) {
|
|
$errors['url'] = 'Put your Azure resource name in place of YOUR-RESOURCE - it is in the Azure portal, under Keys and Endpoint.';
|
|
}
|
|
|
|
$key = (string) ($post['api_key'] ?? '');
|
|
$key = trim($key);
|
|
$saved_key = (string) ($before['ai_provider_api_key'] ?? '');
|
|
if (!empty($post['clear_key'])) {
|
|
$key_after = '';
|
|
} else {
|
|
$key_after = $key !== '' ? $key : $saved_key;
|
|
}
|
|
if (mb_strlen($key_after) > 500) {
|
|
$errors['api_key'] = 'That key is longer than 500 characters.';
|
|
} elseif ($key !== '' && preg_match('/\s/', $key)) {
|
|
$errors['api_key'] = "A key has no spaces in it - check it was copied whole.";
|
|
} elseif ($info['key'] && $key_after === '') {
|
|
$errors['api_key'] = $info['name'] . ' needs an API key' . ($info['keys_url'] !== '' ? ' - make one in its console.' : '.');
|
|
}
|
|
|
|
$after = [
|
|
'ai_provider_name' => $name,
|
|
'ai_provider_kind' => $kind,
|
|
'ai_provider_api_url' => $url,
|
|
'ai_provider_api_key' => $key_after,
|
|
'ai_provider_redact' => !empty($post['redact']) ? 1 : 0,
|
|
];
|
|
return [$after, $errors];
|
|
}
|
|
|
|
/* Whether what a provider is called through - kind, address, key - is new or changed, so worth asking it. */
|
|
function aiProviderConnectionChanged(array $after, $before) {
|
|
if (!$before) {
|
|
return true;
|
|
}
|
|
return $after['ai_provider_kind'] !== (string) $before['ai_provider_kind'] || $after['ai_provider_api_url'] !== (string) $before['ai_provider_api_url']
|
|
|| $after['ai_provider_api_key'] !== (string) $before['ai_provider_api_key'];
|
|
}
|
|
|
|
/* Save a provider - added when there is no $provider_id. Returns its id. */
|
|
function aiProviderWrite(array $after, $provider_id = 0) {
|
|
global $mysqli;
|
|
$set = [];
|
|
foreach ($after as $column => $value) {
|
|
$set[] = is_int($value) ? "$column = $value" : "$column = " . ($column === 'ai_provider_api_key' && $value === '' ? 'NULL' : "'" . mysqli_real_escape_string($mysqli, $value) . "'");
|
|
}
|
|
$provider_id = intval($provider_id);
|
|
if ($provider_id) {
|
|
mysqli_query($mysqli, "UPDATE ai_providers SET " . implode(', ', $set) . " WHERE ai_provider_id = $provider_id");
|
|
return $provider_id;
|
|
}
|
|
mysqli_query($mysqli, "INSERT INTO ai_providers SET " . implode(', ', $set));
|
|
return intval(mysqli_insert_id($mysqli));
|
|
}
|
|
|
|
/* What changed on a provider, a line each, from what to what - the key only as changed. Plain text. */
|
|
function aiProviderChangeLines(array $before, array $after) {
|
|
$lines = [];
|
|
if ((string) $before['ai_provider_name'] !== $after['ai_provider_name']) {
|
|
$lines[] = "Name {$before['ai_provider_name']} → {$after['ai_provider_name']}";
|
|
}
|
|
if ((string) $before['ai_provider_kind'] !== $after['ai_provider_kind']) {
|
|
$lines[] = 'Kind ' . aiProviderKind($before['ai_provider_kind'])['name'] . ' → ' . aiProviderKind($after['ai_provider_kind'])['name'];
|
|
}
|
|
if ((string) $before['ai_provider_api_url'] !== $after['ai_provider_api_url']) {
|
|
$lines[] = "Address {$before['ai_provider_api_url']} → {$after['ai_provider_api_url']}";
|
|
}
|
|
$was_key = (string) $before['ai_provider_api_key'];
|
|
if ($was_key !== $after['ai_provider_api_key']) {
|
|
$lines[] = $was_key === '' ? 'API key added' : ($after['ai_provider_api_key'] === '' ? 'API key removed' : 'API key changed');
|
|
}
|
|
if (intval($before['ai_provider_redact']) !== $after['ai_provider_redact']) {
|
|
$lines[] = $after['ai_provider_redact'] ? 'Passwords and keys taken out of what is sent' : 'Passwords and keys sent as they are';
|
|
}
|
|
return $lines;
|
|
}
|
|
|
|
/* A model as sent from its window, checked - [the row to save (raw values), field => what is wrong]. */
|
|
function aiModelCheck(array $post) {
|
|
$errors = [];
|
|
$provider = aiProviderRow($post['provider'] ?? 0);
|
|
if (!$provider) {
|
|
$errors['provider'] = 'Pick the provider it is on.';
|
|
}
|
|
$name = trim((string) ($post['model'] ?? ''));
|
|
if ($name === '') {
|
|
$errors['model'] = "Its name is needed, as the provider knows it" . ($provider ? ' - like ' . aiProviderKind($provider['ai_provider_kind'])['example'] : '') . '.';
|
|
} elseif (mb_strlen($name) > 200) {
|
|
$errors['model'] = 'That name is longer than 200 characters.';
|
|
} elseif (preg_match('/\s/', $name) && !in_array($provider['ai_provider_kind'] ?? '', ['azure', 'custom'], true)) {
|
|
$errors['model'] = "A model's name has no spaces - " . aiProviderKind($provider['ai_provider_kind'] ?? 'custom')['example'] . ', say.';
|
|
}
|
|
$use_case = (string) ($post['use_case'] ?? '');
|
|
if (!isset(aiUseCases()[$use_case])) {
|
|
$errors['use_case'] = 'Pick what it is for.';
|
|
}
|
|
$temperature = trim((string) ($post['temperature'] ?? ''));
|
|
if ($temperature !== '' && (!is_numeric($temperature) || floatval($temperature) < 0 || floatval($temperature) > 2)) {
|
|
$errors['temperature'] = 'A temperature is from 0 to 2 - or leave it empty for the model\'s own.';
|
|
}
|
|
$prompt = trim(str_replace("\r\n", "\n", (string) ($post['prompt'] ?? '')));
|
|
if (mb_strlen($prompt) > 4000) {
|
|
$errors['prompt'] = 'Keep the instructions under 4,000 characters - they go with every request.';
|
|
}
|
|
$after = [
|
|
'ai_model_ai_provider_id' => intval($provider['ai_provider_id'] ?? 0),
|
|
'ai_model_name' => $name,
|
|
'ai_model_use_case' => $use_case,
|
|
'ai_model_temperature' => $temperature === '' ? null : round(floatval($temperature), 2),
|
|
'ai_model_prompt' => $prompt,
|
|
];
|
|
return [$after, $errors, $provider];
|
|
}
|
|
|
|
/* Save a model - added when there is no $model_id. Returns its id. */
|
|
function aiModelWrite(array $after, $model_id = 0) {
|
|
global $mysqli;
|
|
$set = "ai_model_ai_provider_id = " . intval($after['ai_model_ai_provider_id']) . ",
|
|
ai_model_name = '" . mysqli_real_escape_string($mysqli, $after['ai_model_name']) . "',
|
|
ai_model_use_case = '" . mysqli_real_escape_string($mysqli, $after['ai_model_use_case']) . "',
|
|
ai_model_temperature = " . ($after['ai_model_temperature'] === null ? 'NULL' : floatval($after['ai_model_temperature'])) . ",
|
|
ai_model_prompt = " . ($after['ai_model_prompt'] === '' ? 'NULL' : "'" . mysqli_real_escape_string($mysqli, $after['ai_model_prompt']) . "'");
|
|
$model_id = intval($model_id);
|
|
if ($model_id) {
|
|
mysqli_query($mysqli, "UPDATE ai_models SET $set WHERE ai_model_id = $model_id");
|
|
return $model_id;
|
|
}
|
|
mysqli_query($mysqli, "INSERT INTO ai_models SET $set");
|
|
return intval(mysqli_insert_id($mysqli));
|
|
}
|
|
|
|
/* A temperature as it reads - "the model's own" when there is none. */
|
|
function aiTemperatureText($temperature) {
|
|
return $temperature === null || $temperature === '' ? "the model's own" : rtrim(rtrim(number_format(floatval($temperature), 2, '.', ''), '0'), '.');
|
|
}
|
|
|
|
/* What changed on a model, a line each, from what to what - the instructions only as changed. Plain text. */
|
|
function aiModelChangeLines(array $before, array $after) {
|
|
$lines = [];
|
|
if (intval($before['ai_model_ai_provider_id']) !== $after['ai_model_ai_provider_id']) {
|
|
$lines[] = 'Provider ' . ($before['ai_provider_name'] ?? '#' . $before['ai_model_ai_provider_id']) . ' → ' . (aiProviderRow($after['ai_model_ai_provider_id'])['ai_provider_name'] ?? '');
|
|
}
|
|
if ((string) $before['ai_model_name'] !== $after['ai_model_name']) {
|
|
$lines[] = "Model {$before['ai_model_name']} → {$after['ai_model_name']}";
|
|
}
|
|
if ((string) $before['ai_model_use_case'] !== $after['ai_model_use_case']) {
|
|
$lines[] = "For {$before['ai_model_use_case']} → {$after['ai_model_use_case']}";
|
|
}
|
|
$was = $before['ai_model_temperature'] === null ? null : round(floatval($before['ai_model_temperature']), 2);
|
|
if ($was !== $after['ai_model_temperature']) {
|
|
$lines[] = 'Temperature ' . aiTemperatureText($was) . ' → ' . aiTemperatureText($after['ai_model_temperature']);
|
|
}
|
|
if (trim((string) $before['ai_model_prompt']) !== $after['ai_model_prompt']) {
|
|
$lines[] = $after['ai_model_prompt'] === '' ? 'Instructions removed' : (trim((string) $before['ai_model_prompt']) === '' ? 'Instructions added' : 'Instructions changed');
|
|
}
|
|
return $lines;
|
|
}
|
|
|
|
/*
|
|
* Which model each use case lands on - use case => ['model' => the model row
|
|
* it uses (or null), 'own' => whether that is its own or General's]. And
|
|
* 'unused' - the models nothing lands on, model id => the row used instead: a
|
|
* second model for a use case is never used, the first one added is. $without
|
|
* leaves out a provider's models, or a model ('provider' / 'model' => id) - what
|
|
* it would be once they are deleted.
|
|
*/
|
|
function aiUseCaseMap(array $without = []) {
|
|
global $mysqli;
|
|
$first = [];
|
|
$unused = [];
|
|
$sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_use_case, ai_model_ai_provider_id, ai_provider_name FROM ai_models
|
|
JOIN ai_providers ON ai_provider_id = ai_model_ai_provider_id
|
|
WHERE ai_model_ai_provider_id <> " . intval($without['provider'] ?? 0) . " AND ai_model_id <> " . intval($without['model'] ?? 0) . "
|
|
ORDER BY ai_model_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$use_case = (string) $row['ai_model_use_case'];
|
|
if (!isset(aiUseCases()[$use_case])) {
|
|
$unused[intval($row['ai_model_id'])] = null;
|
|
} elseif (isset($first[$use_case])) {
|
|
$unused[intval($row['ai_model_id'])] = $first[$use_case];
|
|
} else {
|
|
$first[$use_case] = $row;
|
|
}
|
|
}
|
|
$map = [];
|
|
foreach (aiUseCases() as $use_case => $about) {
|
|
$own = $first[$use_case] ?? null;
|
|
$map[$use_case] = ['model' => $own ?? ($first['General'] ?? null), 'own' => $own !== null];
|
|
}
|
|
return ['map' => $map, 'unused' => $unused];
|
|
}
|
|
|
|
/* What would stop if a provider or model went ($without as aiUseCaseMap()) - the use cases that have a model now and wouldn't. */
|
|
function aiUseCasesStopping(array $without) {
|
|
$now = aiUseCaseMap()['map'];
|
|
$then = aiUseCaseMap($without)['map'];
|
|
$stopping = [];
|
|
foreach ($now as $use_case => $lands) {
|
|
if ($lands['model'] && !$then[$use_case]['model']) {
|
|
$stopping[] = $use_case;
|
|
}
|
|
}
|
|
return $stopping;
|
|
}
|
|
|
|
/* A provider kind's icon - Font Awesome classes. */
|
|
function aiProviderIcon($kind) {
|
|
$icons = ['azure' => 'fab fa-microsoft', 'gemini' => 'fab fa-google', 'ollama' => 'fas fa-server', 'lmstudio' => 'fas fa-server'];
|
|
return $icons[(string) $kind] ?? 'fas fa-robot';
|
|
}
|
|
|
|
/*
|
|
* Which model each use case lands on, a pill each - for the top of AI
|
|
* Providers and AI Models, so it is plain what Reword, the ticket summary and
|
|
* document drafts will use.
|
|
*/
|
|
function aiUseCaseStripHtml() {
|
|
$html = '<div class="d-flex flex-wrap gap-2" data-ai-use-cases>';
|
|
foreach (aiUseCaseMap()['map'] as $use_case => $lands) {
|
|
$model = $lands['model'];
|
|
if (!$model) {
|
|
$says = '<span class="text-warning-emphasis">nothing set up</span>';
|
|
$tone = 'border-warning-subtle bg-warning-subtle';
|
|
} elseif ($lands['own']) {
|
|
$says = '<span class="fw-semibold">' . escapeHtml($model['ai_model_name']) . '</span> <span class="text-body-secondary">on ' . escapeHtml($model['ai_provider_name']) . '</span>';
|
|
$tone = 'bg-body';
|
|
} else {
|
|
$says = '<span class="text-body-secondary">General\'s - ' . escapeHtml($model['ai_model_name']) . '</span>';
|
|
$tone = 'bg-body';
|
|
}
|
|
$html .= '<div class="border rounded-pill px-3 py-1 small text-nowrap ' . $tone . '" data-ai-use-case="' . escapeHtml($use_case) . '" title="' . escapeHtml(aiUseCases()[$use_case]) . '">'
|
|
. '<i class="fas fa-fw ' . (['General' => 'fa-magic', 'Tickets' => 'fa-life-ring', 'Documentation' => 'fa-file-alt', 'Billing' => 'fa-file-invoice-dollar'][$use_case] ?? 'fa-robot') . ' text-secondary me-1"></i>'
|
|
. escapeHtml($use_case) . ' <i class="fas fa-long-arrow-alt-right text-secondary mx-1"></i>' . $says . '</div>';
|
|
}
|
|
return $html . '</div>';
|
|
}
|
|
|
|
/*
|
|
* What Admin > Settings says needs attention about AI - requests failing in
|
|
* the last day while the last one failed too (so it is still failing). Items
|
|
* as the overview takes them: [level, icon, title, text, url, button], plain text.
|
|
*/
|
|
function aiProblems() {
|
|
global $mysqli;
|
|
if (!aiUsageTableExists()) {
|
|
return [];
|
|
}
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM ai_usage WHERE ai_usage_feature <> 'test' AND ai_usage_ok = 0 AND ai_usage_created_at >= NOW() - INTERVAL 1 DAY"));
|
|
$failed = intval($row[0] ?? 0);
|
|
if (!$failed) {
|
|
return [];
|
|
}
|
|
$last = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ai_usage_ok, ai_usage_provider_name, ai_usage_error FROM ai_usage WHERE ai_usage_feature <> 'test' ORDER BY ai_usage_id DESC LIMIT 1"));
|
|
if (!$last || intval($last['ai_usage_ok']) === 1) {
|
|
return [];
|
|
}
|
|
return [['warning', 'fa-robot', $failed . ' AI request' . ($failed === 1 ? '' : 's') . ' failed today',
|
|
'The last, to ' . $last['ai_usage_provider_name'] . ', too' . ($last['ai_usage_error'] ? ' - ' . $last['ai_usage_error'] : '') . '. Reword and summaries fail for agents until it is put right.',
|
|
'ai_usage.php?view=failed', 'See why']];
|
|
}
|