AI stage 1: rebuild AI Providers and AI Models in the standard list pattern with ready-made provider kinds (OpenAI, Anthropic, Azure OpenAI, Gemini, Mistral, Groq, OpenRouter, Ollama, LM Studio, OpenAI-compatible), Test that lists a provider's models, Save anyway for a provider that doesn't answer, a first model offered when adding the first provider, the API key no longer shown, from-to audit lines, and delete confirms that say what goes and what stops; model window with suggested models, use case notes and Try it; new AI Usage page logging each request by feature, user, client, model, tokens, time and failure, with totals, views and filters, pruned with the audit log; passwords, keys, 2FA links and card numbers taken out before sending and put back in the answer, on by default per provider; new Use AI role permission; Reword shown only when set up and allowed, placed first in the toolbar, with its own instructions, undo in one step and a fixed endpoint on admin pages; ticket summary and template drafts purified; Settings overview warns while AI requests keep failing; database 3.3.8 adds provider kind and redact, ai_usage and module_ai

This commit is contained in:
johnnyq
2026-10-20 17:45:00 -04:00
parent 8d3fbcb2ab
commit 80cdc4de41
25 changed files with 2557 additions and 695 deletions

View File

@@ -1,131 +1,210 @@
<?php
/*
* Admin > AI Models - the models on the AI providers, each for a use case
* (functions/ai.php aiUseCases()) with General the fallback. Above, which
* model each use case lands on; in the list, a second model for a use case is
* marked as not used, the provider each is on, its instructions and
* temperature, and its use in the last 30 days. Add and Edit are one window
* (modals/ai/ai_model_edit.php), with Try it; the rest are
* admin/post/ai_models.php.
*/
// Default Column Sortby Filter
$sort = "ai_model_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_provider_id,
ai_provider_name FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id
WHERE ai_model_name LIKE '%$q%' OR ai_provider_name LIKE '%$q%'
ORDER BY $sort $order LIMIT $record_from, $record_to");
$page_title = 'AI Models';
// Only these sort - an unknown column would take the page down
$sortable_columns = [
'ai_model_name' => "ai_model_name $order",
'ai_provider_name' => "ai_provider_name $order, ai_model_name ASC",
'ai_model_use_case' => "FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation') " . ($order === 'ASC' ? 'ASC' : 'DESC') . ", ai_model_id ASC",
'requests' => "requests $order, ai_model_name ASC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'ai_model_name';
}
$provider_filter = intval($_GET['provider'] ?? 0);
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_model_temperature,
ai_provider_id, ai_provider_name, ai_provider_kind,
COALESCE(used.requests, 0) AS requests, COALESCE(used.failed, 0) AS failed, COALESCE(used.tokens_in, 0) AS tokens_in, COALESCE(used.tokens_out, 0) AS tokens_out, used.last_used
FROM ai_models
JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id
LEFT JOIN (
SELECT ai_usage_model_id,
SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY) AS requests,
SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY AND ai_usage_ok = 0) AS failed,
SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_in, 0)) AS tokens_in,
SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_out, 0)) AS tokens_out,
MAX(ai_usage_created_at) AS last_used
FROM ai_usage WHERE ai_usage_feature <> 'test' GROUP BY ai_usage_model_id
) used ON used.ai_usage_model_id = ai_model_id
WHERE (ai_model_name LIKE '%$q%' OR ai_provider_name LIKE '%$q%' OR ai_model_use_case LIKE '%$q%' OR ai_model_prompt LIKE '%$q%')
" . ($provider_filter ? "AND ai_provider_id = $provider_filter" : '') . "
ORDER BY {$sortable_columns[$sort]}, ai_model_id ASC
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$unused = aiUseCaseMap()['unused'];
$providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name, ai_provider_kind FROM ai_providers ORDER BY ai_provider_name");
$any_provider = mysqli_num_rows($providers) > 0;
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$add_url = 'modals/ai/ai_model_edit.php' . ($provider_filter ? "?provider=$provider_filter" : '');
$csrf = $_SESSION['csrf_token'];
?>
<ol class="breadcrumb d-print-none">
<li class="breadcrumb-item">
<a href="/admin">Admin</a>
</li>
<li class="breadcrumb-item">
<a href="ai_providers.php">AI Providers</a>
</li>
<li class="breadcrumb-item"><a href="/admin">Admin</a></li>
<li class="breadcrumb-item"><a href="ai_providers.php">AI Providers</a></li>
<li class="breadcrumb-item active">AI Models</li>
</ol>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-robot me-2"></i>AI Models</h3>
<h3 class="card-title mt-2"><i class="fas fa-fw fa-brain me-2"></i>AI Models</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/ai/ai_model_add.php"><i class="fas fa-plus me-2"></i>Add Model</button>
<a class="btn btn-outline-light me-1" href="ai_usage.php" title="Each request - who, for what, and the tokens it took"><i class="fas fa-fw fa-chart-bar"></i><span class="d-none d-lg-inline ms-2">Usage</span></a>
<?php if ($any_provider) { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="<?= $add_url ?>"><i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">Add Model</span></button>
<?php } ?>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<div class="col-lg-5 col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search AI Models">
<button class="btn btn-primary" title="Search"><i class="fa fa-search"></i></button>
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search AI models..." title="Name, provider, use case or instructions">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-lg-3 col-md-6">
<select class="form-select select2" name="provider" onchange="this.form.submit()" aria-label="Provider">
<option value="">- Any provider -</option>
<?php while ($provider = mysqli_fetch_assoc($providers)) { ?>
<option value="<?= intval($provider['ai_provider_id']) ?>" <?= intval($provider['ai_provider_id']) === $provider_filter ? 'selected' : '' ?><?= selectOptionData(['icon' => aiProviderIcon($provider['ai_provider_kind'])]) ?>><?= escapeHtml($provider['ai_provider_name']) ?></option>
<?php } ?>
</select>
</div>
</div>
</form>
<?php if ($any_provider) { ?>
<div class="mt-3"><?= aiUseCaseStripHtml() ?></div>
<?php } ?>
</div>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover mb-0">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_model_name&order=<?= $disp ?>">
Model <?php if ($sort == 'ai_model_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_name&order=<?= $disp ?>">
Provider <?php if ($sort == 'ai_provider_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_model_use_case&order=<?= $disp ?>">
Use Case<?php if ($sort == 'ai_model_use_case') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark">Prompt</a>
</th>
<th class="text-center">Action</th>
<th class="ps-3"><?= $sort_link('ai_model_name', 'Model') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('ai_provider_name', 'Provider') ?></th>
<th><?= $sort_link('ai_model_use_case', 'For') ?></th>
<th class="d-none d-lg-table-cell">Instructions</th>
<th class="d-none d-md-table-cell"><?= $sort_link('requests', 'Last 30 days') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$provider_id = intval($row['ai_provider_id']);
$provider_name = escapeHtml($row['ai_provider_name']);
<?php while ($row = mysqli_fetch_assoc($sql)) {
$model_id = intval($row['ai_model_id']);
$model_name = escapeHtml($row['ai_model_name']);
$use_case = escapeHtml($row['ai_model_use_case']);
$prompt = nl2br(escapeHtml($row['ai_model_prompt']));
$name = (string) $row['ai_model_name'];
$use_case = (string) $row['ai_model_use_case'];
$edit_url = "modals/ai/ai_model_edit.php?id=$model_id";
$prompt = trim((string) $row['ai_model_prompt']);
$requests = intval($row['requests']);
$failed = intval($row['failed']);
$is_unused = array_key_exists($model_id, $unused);
$stopping = $is_unused ? [] : aiUseCasesStopping(['model' => $model_id]);
$then = $is_unused ? null : aiUseCaseMap(['model' => $model_id])['map'][$use_case] ?? null;
if ($is_unused) {
$delete_text = 'Nothing uses it - ' . ($unused[$model_id] ? $unused[$model_id]['ai_model_name'] . " is the one used for $use_case." : "$use_case isn't a use case.");
} elseif ($stopping) {
$delete_text = 'AI stops for ' . implode(' and ', $stopping) . ' until another model is set up.';
} else {
$delete_text = "$use_case uses " . ($then && $then['model'] ? $then['model']['ai_model_name'] . ($then['own'] ? '' : ' (General)') : 'nothing') . ' instead.';
}
?>
<tr>
<tr data-ai-model-id="<?= $model_id ?>" class="<?= $is_unused ? 'text-body-secondary' : '' ?>">
<td class="ps-3">
<a class="text-dark text-bold ajax-modal" href="#"
data-modal-url="modals/ai/ai_model_edit.php?id=<?= $model_id ?>">
<?= $model_name ?>
</a>
<a class="fw-semibold text-body font-monospace ajax-modal" href="#" data-modal-url="<?= $edit_url ?>"><?= escapeHtml($name) ?></a>
<?php if ($is_unused) { ?>
<span class="badge text-bg-light border fw-normal ms-1" data-model-unused title="<?= escapeHtml($unused[$model_id] ? $unused[$model_id]['ai_model_name'] . " is for $use_case already, and is the one used" : "$use_case isn't a use case") ?>">Not used</span>
<?php } ?>
<div class="d-md-none small text-body-secondary"><?= escapeHtml($row['ai_provider_name']) ?></div>
<?php if ($row['ai_model_temperature'] !== null) { ?>
<div class="small text-body-secondary" title="Temperature">Temperature <?= escapeHtml(aiTemperatureText($row['ai_model_temperature'])) ?></div>
<?php } ?>
</td>
<td><?= $provider_name ?></td>
<td><?= $use_case ?></td>
<td><?= $prompt ?></td>
<td>
<div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" title="More actions" type="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<td class="d-none d-md-table-cell small">
<a class="text-reset" href="?provider=<?= intval($row['ai_provider_id']) ?>"><i class="<?= aiProviderIcon($row['ai_provider_kind']) ?> fa-fw text-secondary me-1"></i><?= escapeHtml($row['ai_provider_name']) ?></a>
</td>
<td class="small">
<div class="fw-semibold"><?= escapeHtml($use_case) ?></div>
<div class="text-body-secondary d-none d-sm-block"><?= escapeHtml(aiUseCases()[$use_case] ?? '') ?></div>
</td>
<td class="d-none d-lg-table-cell small" style="max-width: 22rem;">
<?php if ($prompt !== '') { ?>
<div class="text-truncate" title="<?= escapeHtml($prompt) ?>"><?= escapeHtml($prompt) ?></div>
<?php } else { ?>
<span class="text-body-secondary">None</span>
<?php } ?>
</td>
<td class="d-none d-md-table-cell small text-nowrap" data-model-requests="<?= $requests ?>">
<?php if ($requests) { ?>
<div><a class="text-reset" href="ai_usage.php?model=<?= $model_id ?>"><?= number_format($requests) ?> request<?= $requests === 1 ? '' : 's' ?></a></div>
<div class="text-body-secondary" title="Tokens sent and received"><?= aiTokensText($row['tokens_in']) ?> in · <?= aiTokensText($row['tokens_out']) ?> out</div>
<?php if ($failed) { ?>
<div><a class="text-danger-emphasis" href="ai_usage.php?model=<?= $model_id ?>&amp;view=failed"><?= $failed ?> failed</a></div>
<?php } ?>
<?php } else { ?>
<div class="text-body-secondary"><?= $row['last_used'] ? 'None' : 'Not used yet' ?></div>
<?php } ?>
<?php if ($row['last_used']) { ?>
<div class="text-body-secondary" title="<?= escapeHtml($row['last_used']) ?>">Last <?= escapeHtml(timeAgo($row['last_used'])) ?></div>
<?php } ?>
</td>
<td class="text-end pe-3">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/ai/ai_model_edit.php?id=<?= $model_id ?>">
<i class="fas fa-fw fa-edit me-2"></i>Edit
<a class="dropdown-item ajax-modal" href="#" data-modal-url="<?= $edit_url ?>">
<i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit
</a>
<a class="dropdown-item" href="ai_usage.php?model=<?= $model_id ?>">
<i class="fas fa-fw fa-chart-bar text-secondary me-2"></i>Usage
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_model=<?= $model_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_model=<?= $model_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Delete <?= escapeHtml($name) ?>?" data-confirm-button="Delete" data-confirm-text="<?= escapeHtml($delete_text) ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php
}
?>
<?php } ?>
</tbody>
</table>
</div>
<?php
$empty_state = [
'thing' => 'AI models',
'icon' => 'fa-robot',
'text' => 'The models ITFlow uses for ticket summaries and rewording - add a provider first.',
'actions' => [['label' => 'Add Model', 'modal' => 'modals/ai/ai_model_add.php']],
'icon' => 'fa-brain',
'text' => $any_provider ? 'The model on each provider that ITFlow uses - one for everything (General), and others for tickets or documents if you like.' : 'Add an AI provider first - then the models on it ITFlow uses.',
'actions' => $any_provider ? [['label' => 'Add Model', 'modal' => $add_url]] : [['label' => 'AI Providers', 'href' => 'ai_providers.php', 'icon' => 'fa-robot']],
];
?>
<?php require_once "../includes/filter_footer.php"; ?>

View File

@@ -1,113 +1,209 @@
<?php
/*
* Admin > AI Providers - the AI services ITFlow asks (functions/ai.php): a
* ready-made kind each (OpenAI, Claude, Gemini, Azure, a model on your own
* network...), its address, whether its key is saved (never shown), whether
* passwords and keys are taken out of what is sent to it, the models on it and
* what each is for, and its use in the last 30 days. Above, which model each
* use case lands on. Test asks it for its models; Add and Edit are one window
* (modals/ai/ai_provider_edit.php); the rest are admin/post/ai_provider.php.
*/
// Default Column Sortby Filter
$sort = "ai_provider_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_provider_api_key, ai_provider_api_url, ai_provider_id, ai_provider_name FROM ai_providers
WHERE ai_provider_name LIKE '%$q%' OR ai_provider_api_url LIKE '%$q%'
ORDER BY $sort $order LIMIT $record_from, $record_to");
// Only these sort - an unknown column would take the page down
$sortable_columns = [
'ai_provider_name' => "ai_provider_name $order",
'ai_provider_kind' => "ai_provider_kind $order, ai_provider_name ASC",
'model_count' => "model_count $order, ai_provider_name ASC",
'requests' => "requests $order, ai_provider_name ASC",
'last_used' => "last_used IS NULL, last_used $order, ai_provider_name ASC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'ai_provider_name';
}
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_provider_id, ai_provider_name, ai_provider_kind, ai_provider_api_url, ai_provider_redact,
ai_provider_api_key IS NOT NULL AND ai_provider_api_key <> '' AS has_key,
(SELECT COUNT(*) FROM ai_models WHERE ai_model_ai_provider_id = ai_provider_id) AS model_count,
COALESCE(used.requests, 0) AS requests, COALESCE(used.failed, 0) AS failed, COALESCE(used.tokens_in, 0) AS tokens_in, COALESCE(used.tokens_out, 0) AS tokens_out, used.last_used
FROM ai_providers
LEFT JOIN (
SELECT ai_usage_provider_id,
SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY) AS requests,
SUM(ai_usage_created_at >= NOW() - INTERVAL 30 DAY AND ai_usage_ok = 0) AS failed,
SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_in, 0)) AS tokens_in,
SUM(IF(ai_usage_created_at >= NOW() - INTERVAL 30 DAY, ai_usage_tokens_out, 0)) AS tokens_out,
MAX(ai_usage_created_at) AS last_used
FROM ai_usage WHERE ai_usage_feature <> 'test' GROUP BY ai_usage_provider_id
) used ON used.ai_usage_provider_id = ai_provider_id
WHERE (ai_provider_name LIKE '%$q%' OR ai_provider_kind LIKE '%$q%' OR ai_provider_api_url LIKE '%$q%'
OR EXISTS (SELECT 1 FROM ai_models WHERE ai_model_ai_provider_id = ai_provider_id AND ai_model_name LIKE '%$q%'))
ORDER BY {$sortable_columns[$sort]}, ai_provider_id ASC
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
// Each provider's models, with what they are for - and those nothing uses
$models_by_provider = [];
$model_sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_use_case, ai_model_ai_provider_id FROM ai_models ORDER BY FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation'), ai_model_name");
while ($model = mysqli_fetch_assoc($model_sql)) {
$models_by_provider[intval($model['ai_model_ai_provider_id'])][] = $model;
}
$unused = aiUseCaseMap()['unused'];
$any_provider = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM ai_providers"))[0]) > 0;
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$add_url = 'modals/ai/ai_provider_edit.php';
$csrf = $_SESSION['csrf_token'];
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-robot me-2"></i>AI Providers</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/ai/ai_provider_add.php"><i class="fas fa-plus me-2"></i>Add Provider</button>
<a class="btn btn-outline-light me-1" href="ai_models.php" title="The models on them, and what each is for"><i class="fas fa-fw fa-brain"></i><span class="d-none d-lg-inline ms-2">Models</span></a>
<a class="btn btn-outline-light me-1" href="ai_usage.php" title="Each request - who, for what, and the tokens it took"><i class="fas fa-fw fa-chart-bar"></i><span class="d-none d-lg-inline ms-2">Usage</span></a>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="<?= $add_url ?>"><i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">Add Provider</span></button>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<div class="col-lg-5 col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search AI Providers">
<button class="btn btn-primary" title="Search"><i class="fa fa-search"></i></button>
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search AI providers..." title="Name, kind, address or a model on it">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
</div>
</form>
<?php if ($any_provider) { ?>
<div class="mt-3"><?= aiUseCaseStripHtml() ?></div>
<?php } ?>
</div>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover mb-0">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_name&order=<?= $disp ?>">
Provider <?php if ($sort == 'ai_provider_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_api_url&order=<?= $disp ?>">
URL <?php if ($sort == 'ai_provider_api_url') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_api_key&order=<?= $disp ?>">
Key <?php if ($sort == 'ai_provider_api_key') { echo $order_icon; } ?>
</a>
</th>
<th class="text-center">
<a class="text-dark">Models</a>
</th>
<th class="text-center">Action</th>
<th class="ps-3"><?= $sort_link('ai_provider_name', 'Provider') ?></th>
<th class="d-none d-lg-table-cell"><?= $sort_link('ai_provider_kind', 'Kind') ?></th>
<th class="d-none d-md-table-cell">Key</th>
<th class="d-none d-md-table-cell"><?= $sort_link('model_count', 'Models') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('requests', 'Last 30 days') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
<?php while ($row = mysqli_fetch_assoc($sql)) {
$provider_id = intval($row['ai_provider_id']);
$provider_name = escapeHtml($row['ai_provider_name']);
$url = escapeHtml($row['ai_provider_api_url']);
$key = escapeHtml($row['ai_provider_api_key']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('ai_model_id') AS ai_model_count FROM ai_models WHERE ai_model_ai_provider_id = $provider_id"));
$ai_model_count = intval($row['ai_model_count']);
$name = (string) $row['ai_provider_name'];
$kind = aiProviderKind($row['ai_provider_kind']);
$host = (string) parse_url((string) $row['ai_provider_api_url'], PHP_URL_HOST);
$port = parse_url((string) $row['ai_provider_api_url'], PHP_URL_PORT);
$edit_url = "modals/ai/ai_provider_edit.php?id=$provider_id";
$models = $models_by_provider[$provider_id] ?? [];
$requests = intval($row['requests']);
$failed = intval($row['failed']);
$stopping = aiUseCasesStopping(['provider' => $provider_id]);
$model_names = array_column($models, 'ai_model_name');
$delete_text = ($models ? (count($models) === 1 ? "Its model, {$model_names[0]}, goes with it." : 'Its ' . count($models) . ' models go with it - ' . implode(', ', $model_names) . '.') : 'It has no models.')
. ($stopping ? ' AI stops for ' . implode(' and ', $stopping) . ' until another model is set up.' : '');
?>
<tr>
<tr data-ai-provider-id="<?= $provider_id ?>">
<td class="ps-3">
<a class="text-dark text-bold ajax-modal" href="#"
data-modal-url="modals/ai/ai_provider_edit.php?id=<?= $provider_id ?>">
<?= $provider_name ?>
</a>
<div class="d-flex align-items-center gap-3">
<span class="d-none d-sm-inline-flex itflow-asset-icon itflow-asset-tone-other" aria-hidden="true"><i class="<?= aiProviderIcon($row['ai_provider_kind']) ?>"></i></span>
<div class="min-w-0">
<a class="fw-semibold text-body ajax-modal" href="#" data-modal-url="<?= $edit_url ?>"><?= escapeHtml($name) ?></a>
<?php if ($kind['local']) { ?>
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle ms-1" title="It runs on your own network - what is sent to it doesn't leave it">Your network</span>
<?php } ?>
<?php if (!intval($row['ai_provider_redact'])) { ?>
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle ms-1" data-provider-redact="off" title="Passwords and keys in what is sent go to it as they are">Secrets sent</span>
<?php } ?>
<div class="small text-body-secondary text-truncate" style="max-width: 22rem;" title="<?= escapeHtml($row['ai_provider_api_url']) ?>"><?= escapeHtml($host . ($port ? ":$port" : '')) ?></div>
<!-- On smaller screens its kind and models come along under the name -->
<div class="d-lg-none small text-body-secondary"><?= escapeHtml($kind['name']) ?><span class="d-md-none"><?= $models ? ' · ' . escapeHtml(implode(', ', $model_names)) : '' ?></span></div>
</div>
</div>
</td>
<td><?= $url ?></td>
<td><?= $key ?></td>
<td class="text-center">
<a class="badge bg-dark rounded-pill p-2" href="ai_models.php"><?= $ai_model_count ?></a>
<td>
<div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" title="More actions" type="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<td class="d-none d-lg-table-cell small"><?= escapeHtml($kind['name']) ?></td>
<td class="d-none d-md-table-cell small text-nowrap" data-provider-key="<?= intval($row['has_key']) ? 'saved' : 'none' ?>">
<?php if (intval($row['has_key'])) { ?>
<i class="fas fa-fw fa-lock text-secondary me-1"></i>Saved
<?php } elseif ($kind['key']) { ?>
<span class="text-danger-emphasis"><i class="fas fa-fw fa-exclamation-triangle me-1"></i>Needs one - add it in Edit</span>
<?php } else { ?>
<span class="text-body-secondary">None</span>
<?php } ?>
</td>
<td class="d-none d-md-table-cell small">
<?php foreach ($models as $model) {
$model_id = intval($model['ai_model_id']);
?>
<div class="text-nowrap" data-provider-model="<?= escapeHtml($model['ai_model_name']) ?>">
<a class="text-reset ajax-modal" href="#" data-modal-url="modals/ai/ai_model_edit.php?id=<?= $model_id ?>"><?= escapeHtml($model['ai_model_name']) ?></a>
<span class="text-body-secondary">· <?= escapeHtml($model['ai_model_use_case']) ?></span>
<?php if (array_key_exists($model_id, $unused)) { ?>
<span class="badge text-bg-light border fw-normal ms-1" title="<?= escapeHtml($unused[$model_id] ? $unused[$model_id]['ai_model_name'] . ' is for ' . $model['ai_model_use_case'] . ' already, and is the one used' : 'Not one of the use cases') ?>">Not used</span>
<?php } ?>
</div>
<?php } ?>
<?php if (!$models) { ?>
<a class="ajax-modal" href="#" data-modal-url="modals/ai/ai_model_edit.php?provider=<?= $provider_id ?>"><i class="fas fa-fw fa-plus me-1"></i>Add a model</a>
<?php } ?>
</td>
<td class="d-none d-md-table-cell small text-nowrap" data-provider-requests="<?= $requests ?>">
<?php if ($requests) { ?>
<div><a class="text-reset" href="ai_usage.php?provider=<?= $provider_id ?>"><?= number_format($requests) ?> request<?= $requests === 1 ? '' : 's' ?></a></div>
<div class="text-body-secondary" title="Tokens sent and received"><?= aiTokensText($row['tokens_in']) ?> in · <?= aiTokensText($row['tokens_out']) ?> out</div>
<?php if ($failed) { ?>
<div><a class="text-danger-emphasis" href="ai_usage.php?provider=<?= $provider_id ?>&amp;view=failed"><?= $failed ?> failed</a></div>
<?php } ?>
<?php } else { ?>
<div class="text-body-secondary"><?= $row['last_used'] ? 'None' : 'Not used yet' ?></div>
<?php } ?>
<?php if ($row['last_used']) { ?>
<div class="text-body-secondary" title="<?= escapeHtml($row['last_used']) ?>">Last <?= escapeHtml(timeAgo($row['last_used'])) ?></div>
<?php } ?>
</td>
<td class="text-end pe-3">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/ai/ai_provider_edit.php?id=<?= $provider_id ?>">
<i class="fas fa-fw fa-edit me-2"></i>Edit
<a class="dropdown-item ajax-modal" href="#" data-modal-url="<?= $edit_url ?>">
<i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit
</a>
<a class="dropdown-item" href="post.php?test_ai_provider=<?= $provider_id ?>&csrf_token=<?= $csrf ?>" title="Whether it answers, with its key, and has the models set up on it">
<i class="fas fa-fw fa-plug text-secondary me-2"></i>Test
</a>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ai/ai_model_edit.php?provider=<?= $provider_id ?>">
<i class="fas fa-fw fa-plus text-secondary me-2"></i>Add a model
</a>
<a class="dropdown-item" href="ai_usage.php?provider=<?= $provider_id ?>">
<i class="fas fa-fw fa-chart-bar text-secondary me-2"></i>Usage
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_provider=<?= $provider_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_provider=<?= $provider_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Delete <?= escapeHtml($name) ?>?" data-confirm-button="Delete" data-confirm-text="<?= escapeHtml($delete_text) ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php
}
?>
<?php } ?>
</tbody>
</table>
</div>
@@ -115,8 +211,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$empty_state = [
'thing' => 'AI providers',
'icon' => 'fa-robot',
'text' => 'Connect an AI service, then pick the models ITFlow uses from it.',
'actions' => [['label' => 'Add Provider', 'modal' => 'modals/ai/ai_provider_add.php']],
'text' => 'Connect OpenAI, Claude, Gemini, Azure OpenAI, or a model on your own network like Ollama - then agents can reword replies, summarize tickets and draft documents, with passwords and keys taken out of what is sent.',
'actions' => [['label' => 'Add Provider', 'modal' => $add_url]],
];
?>
<?php require_once "../includes/filter_footer.php"; ?>

276
admin/ai_usage.php Normal file
View File

@@ -0,0 +1,276 @@
<?php
/*
* Admin > AI Usage - every request made to an AI provider (ai_usage,
* recorded by callAiApi() in functions/ai.php): when, the feature, who asked,
* the client it was about, the model and provider, the tokens it took, how
* long, how many passwords and keys were taken out of it first, and whether
* it worked - why not, when it didn't. Totals for what is shown sit above.
* Searched, and filtered by date, provider, model, feature and who; views for
* the failed ones, the ones secrets were taken out of, and tests. Kept as long
* as the audit log is (Admin > Settings > Logs).
*/
// Default Column Sortby Filter
$sort = "ai_usage_id";
$order = "DESC";
require_once "includes/inc_all_admin.php";
$page_title = 'AI Usage';
// Only these sort - an unknown column would take the page down
$sortable_columns = [
'ai_usage_id' => "ai_usage_id $order",
'ai_usage_feature' => "ai_usage_feature $order, ai_usage_id DESC",
'user_name' => "user_name IS NULL, user_name $order, ai_usage_id DESC",
'client_name' => "client_name IS NULL, client_name $order, ai_usage_id DESC",
'ai_usage_model' => "ai_usage_model $order, ai_usage_id DESC",
'tokens' => "(ai_usage_tokens_in + ai_usage_tokens_out) $order, ai_usage_id DESC",
'ai_usage_ms' => "ai_usage_ms $order, ai_usage_id DESC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'ai_usage_id';
}
$features = aiFeatures();
$views = [
'' => ['label' => 'All', 'icon' => 'fa-list', 'where' => "ai_usage_feature <> 'test'", 'title' => 'Every request but tests'],
'failed' => ['label' => 'Failed', 'icon' => 'fa-times-circle', 'where' => "ai_usage_feature <> 'test' AND ai_usage_ok = 0", 'title' => 'The provider turned them away, or couldn\'t be reached'],
'redacted' => ['label' => 'Secrets kept out', 'icon' => 'fa-user-secret', 'where' => "ai_usage_redacted > 0", 'title' => 'Passwords or keys were taken out before they were sent'],
'test' => ['label' => 'Tests', 'icon' => 'fa-vial', 'where' => "ai_usage_feature = 'test'", 'title' => 'Try it, from a model\'s window'],
];
$view = is_string($_GET['view'] ?? null) && isset($views[$_GET['view']]) ? $_GET['view'] : '';
$provider_filter = intval($_GET['provider'] ?? 0);
$model_filter = intval($_GET['model'] ?? 0);
$user_filter = intval($_GET['user'] ?? 0);
$feature_filter = is_string($_GET['feature'] ?? null) && isset($features[$_GET['feature']]) ? $_GET['feature'] : '';
$from = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $dtf) ? $dtf : '1970-01-01';
$to = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $dtt) ? $dtt : '2099-12-31';
// Everything but the view - the views are counted with it
$where = ["DATE(ai_usage_created_at) BETWEEN '$from' AND '$to'"];
if ($q !== '') {
$where[] = "(ai_usage_model LIKE '%$q%' OR ai_usage_provider_name LIKE '%$q%' OR ai_usage_feature LIKE '%$q%' OR ai_usage_use_case LIKE '%$q%'
OR user_name LIKE '%$q%' OR client_name LIKE '%$q%' OR ai_usage_error LIKE '%$q%')";
}
if ($provider_filter) {
$where[] = "ai_usage_provider_id = $provider_filter";
}
if ($model_filter) {
$where[] = "ai_usage_model_id = $model_filter";
}
if ($user_filter) {
$where[] = "ai_usage_user_id = $user_filter";
}
if ($feature_filter !== '') {
$where[] = "ai_usage_feature = '" . escapeSql($feature_filter) . "'";
}
$where_sql = implode(' AND ', $where);
$from_sql = "FROM ai_usage LEFT JOIN users ON user_id = ai_usage_user_id LEFT JOIN clients ON client_id = ai_usage_client_id";
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS ai_usage.*, user_name, client_name
$from_sql
WHERE $where_sql AND {$views[$view]['where']}
ORDER BY {$sortable_columns[$sort]}
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
// Each view counted for the rest of the filters, and the totals for this one
$count_sql = [];
foreach ($views as $definition) {
$count_sql[] = "COALESCE(SUM({$definition['where']}), 0)";
}
$counts = array_combine(array_keys($views), array_map('intval', mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " $from_sql WHERE $where_sql"))));
$totals = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS requests, COALESCE(SUM(ai_usage_tokens_in), 0) AS tokens_in, COALESCE(SUM(ai_usage_tokens_out), 0) AS tokens_out,
COALESCE(SUM(ai_usage_ok = 0), 0) AS failed, COALESCE(SUM(ai_usage_redacted), 0) AS redacted, COALESCE(AVG(IF(ai_usage_ok = 1, ai_usage_ms, NULL)), 0) AS ms, COUNT(DISTINCT ai_usage_user_id) AS people
$from_sql WHERE $where_sql AND {$views[$view]['where']}"));
$providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name, ai_provider_kind FROM ai_providers ORDER BY ai_provider_name");
$people = mysqli_query($mysqli, "SELECT DISTINCT user_id, user_name FROM ai_usage JOIN users ON user_id = ai_usage_user_id ORDER BY user_name");
$model_row = $model_filter ? aiModelRow($model_filter) : null;
$retention = appLogRetention();
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
// A view keeps the filters, and starts from its first page
$view_url = function ($key) {
$query = $_GET;
unset($query['page']);
$query['view'] = $key;
return '?' . http_build_query(array_filter($query, function ($value) {
return $value !== '' && $value !== null && $value !== [];
}));
};
// The page without one filter
$without_url = function ($key) {
$query = $_GET;
unset($query['page'], $query[$key]);
return '?' . http_build_query($query);
};
$ms_text = function ($ms) {
$ms = intval($ms);
return $ms >= 1000 ? number_format($ms / 1000, 1) . 's' : $ms . 'ms';
};
?>
<ol class="breadcrumb d-print-none">
<li class="breadcrumb-item"><a href="/admin">Admin</a></li>
<li class="breadcrumb-item"><a href="ai_providers.php">AI Providers</a></li>
<li class="breadcrumb-item active">AI Usage</li>
</ol>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-chart-bar me-2"></i>AI Usage</h3>
<div class="card-tools">
<a class="btn btn-outline-light me-1" href="ai_models.php"><i class="fas fa-fw fa-brain"></i><span class="d-none d-lg-inline ms-2">Models</span></a>
<a class="btn btn-outline-light" href="settings_logs.php" title="Kept as long as the audit log - <?= escapeHtml(appLogRetentionText($retention['audit'])) ?>"><i class="fas fa-fw fa-cog"></i><span class="d-none d-lg-inline ms-2">Keeping</span></a>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<?php foreach (['view' => $view, 'model' => $model_filter ?: ''] as $keep => $value) { ?>
<?php if ($value !== '') { ?><input type="hidden" name="<?= $keep ?>" value="<?= escapeHtml($value) ?>"><?php } ?>
<?php } ?>
<div class="row g-2 align-items-center">
<div class="col-lg-4 col-md-12">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search AI usage" title="Model, provider, feature, who, client or what went wrong">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-lg-2 col-md-6">
<input type="text" id="dateFilter" class="form-control" autocomplete="off" title="When" aria-label="When">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml(is_string($_GET['canned_date'] ?? null) ? $_GET['canned_date'] : '') ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
<div class="col-lg-2 col-md-6">
<select class="form-select select2" name="provider" onchange="this.form.submit()" aria-label="Provider">
<option value="">- Any provider -</option>
<?php while ($provider = mysqli_fetch_assoc($providers)) { ?>
<option value="<?= intval($provider['ai_provider_id']) ?>" <?= intval($provider['ai_provider_id']) === $provider_filter ? 'selected' : '' ?><?= selectOptionData(['icon' => aiProviderIcon($provider['ai_provider_kind'])]) ?>><?= escapeHtml($provider['ai_provider_name']) ?></option>
<?php } ?>
</select>
</div>
<div class="col-lg-2 col-md-6">
<select class="form-select select2" name="feature" onchange="this.form.submit()" aria-label="Feature">
<option value="">- Any feature -</option>
<?php foreach ($features as $key => $label) { ?>
<option value="<?= $key ?>" <?= $key === $feature_filter ? 'selected' : '' ?>><?= escapeHtml($label) ?></option>
<?php } ?>
</select>
</div>
<div class="col-lg-2 col-md-6">
<select class="form-select select2" name="user" onchange="this.form.submit()" aria-label="Who">
<option value="">- Anyone -</option>
<?php while ($person = mysqli_fetch_assoc($people)) { ?>
<option value="<?= intval($person['user_id']) ?>" <?= intval($person['user_id']) === $user_filter ? 'selected' : '' ?>><?= escapeHtml($person['user_name']) ?></option>
<?php } ?>
</select>
</div>
</div>
</form>
<?php if ($model_filter) { ?>
<div class="mt-2 small" data-usage-model-filter>
Just <strong class="font-monospace"><?= escapeHtml($model_row['ai_model_name'] ?? "model #$model_filter") ?></strong>
<a class="ms-1" href="<?= escapeHtml($without_url('model')) ?>" title="Every model"><i class="fas fa-times-circle"></i></a>
</div>
<?php } ?>
<nav class="nav nav-pills itflow-asset-tabs flex-nowrap overflow-auto mt-3">
<?php foreach ($views as $key => $definition) { ?>
<a class="nav-link text-nowrap <?= $view === $key ? 'active' : '' ?>" href="<?= escapeHtml($view_url($key)) ?>" title="<?= escapeHtml($definition['title']) ?>" data-usage-view="<?= $key === '' ? 'all' : $key ?>">
<i class="fas fa-fw <?= $definition['icon'] ?> me-1"></i><?= $definition['label'] ?>
<span class="badge rounded-pill ms-1"><?= number_format($counts[$key]) ?></span>
</a>
<?php } ?>
</nav>
<?php if (intval($totals['requests'])) { ?>
<div class="row row-cols-2 row-cols-md-3 row-cols-xl-6 g-2 mt-1 small" data-usage-totals>
<div class="col"><div class="border rounded px-3 py-2 h-100"><div class="text-body-secondary">Requests</div><div class="fs-5 fw-semibold" data-usage-total="requests"><?= number_format($totals['requests']) ?></div></div></div>
<div class="col"><div class="border rounded px-3 py-2 h-100" title="<?= number_format($totals['tokens_in']) ?> tokens sent"><div class="text-body-secondary">Tokens in</div><div class="fs-5 fw-semibold" data-usage-total="tokens_in"><?= aiTokensText($totals['tokens_in']) ?></div></div></div>
<div class="col"><div class="border rounded px-3 py-2 h-100" title="<?= number_format($totals['tokens_out']) ?> tokens received"><div class="text-body-secondary">Tokens out</div><div class="fs-5 fw-semibold" data-usage-total="tokens_out"><?= aiTokensText($totals['tokens_out']) ?></div></div></div>
<div class="col"><div class="border rounded px-3 py-2 h-100"><div class="text-body-secondary">Failed</div><div class="fs-5 fw-semibold <?= intval($totals['failed']) ? 'text-danger-emphasis' : '' ?>" data-usage-total="failed"><?= number_format($totals['failed']) ?></div></div></div>
<div class="col"><div class="border rounded px-3 py-2 h-100" title="Passwords and keys taken out before they were sent"><div class="text-body-secondary">Secrets kept out</div><div class="fs-5 fw-semibold" data-usage-total="redacted"><?= number_format($totals['redacted']) ?></div></div></div>
<div class="col"><div class="border rounded px-3 py-2 h-100" title="How long an answer took, on average"><div class="text-body-secondary">Answer time</div><div class="fs-5 fw-semibold"><?= $ms_text($totals['ms']) ?></div></div></div>
</div>
<?php } ?>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3"><?= $sort_link('ai_usage_id', 'When') ?></th>
<th><?= $sort_link('ai_usage_feature', 'Feature') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('user_name', 'Who') ?></th>
<th class="d-none d-lg-table-cell"><?= $sort_link('client_name', 'Client') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('ai_usage_model', 'Model') ?></th>
<th class="d-none d-sm-table-cell text-end"><?= $sort_link('tokens', 'Tokens') ?></th>
<th class="d-none d-lg-table-cell text-end"><?= $sort_link('ai_usage_ms', 'Time') ?></th>
</tr>
</thead>
<tbody>
<?php while ($row = mysqli_fetch_assoc($sql)) {
$ok = intval($row['ai_usage_ok']) === 1;
$redacted = intval($row['ai_usage_redacted']);
$feature = (string) $row['ai_usage_feature'];
?>
<tr data-ai-usage-id="<?= intval($row['ai_usage_id']) ?>">
<td class="ps-3 text-nowrap small" title="<?= escapeHtml($row['ai_usage_created_at']) ?>">
<div><?= escapeHtml(date('M j, Y g:i A', strtotime($row['ai_usage_created_at']))) ?></div>
<div class="text-body-secondary"><?= escapeHtml(timeAgo($row['ai_usage_created_at'])) ?></div>
</td>
<td class="small">
<div class="fw-semibold"><?= escapeHtml($features[$feature] ?? ucfirst(str_replace('_', ' ', $feature))) ?></div>
<?php if (!$ok) { ?>
<div class="text-danger-emphasis text-break" data-usage-failed><i class="fas fa-fw fa-times-circle me-1"></i><?= escapeHtml($row['ai_usage_error'] ?: 'Failed') ?></div>
<?php } ?>
<?php if ($redacted) { ?>
<div class="text-body-secondary" data-usage-redacted="<?= $redacted ?>" title="Taken out before it was sent, and put back in the answer"><i class="fas fa-fw fa-user-secret me-1"></i><?= $redacted ?> secret<?= $redacted === 1 ? '' : 's' ?> kept out</div>
<?php } ?>
<div class="d-md-none text-body-secondary"><?= escapeHtml(trim(($row['user_name'] ?? '') . ' · ' . $row['ai_usage_model'], ' ·')) ?></div>
</td>
<td class="d-none d-md-table-cell small"><?= $row['user_name'] !== null ? escapeHtml($row['user_name']) : '<span class="text-body-secondary">' . (intval($row['ai_usage_user_id']) ? 'Removed' : 'ITFlow') . '</span>' ?></td>
<td class="d-none d-lg-table-cell small">
<?php if ($row['client_name'] !== null) { ?>
<a class="text-reset" href="/agent/client_overview.php?client_id=<?= intval($row['ai_usage_client_id']) ?>"><?= escapeHtml($row['client_name']) ?></a>
<?php } else { ?>
<span class="text-body-secondary">-</span>
<?php } ?>
</td>
<td class="d-none d-md-table-cell small">
<div class="font-monospace"><?= escapeHtml($row['ai_usage_model']) ?></div>
<div class="text-body-secondary"><?= escapeHtml($row['ai_usage_provider_name']) ?></div>
</td>
<td class="d-none d-sm-table-cell small text-end text-nowrap" title="<?= number_format($row['ai_usage_tokens_in']) ?> in, <?= number_format($row['ai_usage_tokens_out']) ?> out">
<?php if ($ok) { ?>
<div><?= aiTokensText($row['ai_usage_tokens_in']) ?> in</div>
<div class="text-body-secondary"><?= aiTokensText($row['ai_usage_tokens_out']) ?> out</div>
<?php } else { ?>
<span class="text-body-secondary">-</span>
<?php } ?>
</td>
<td class="d-none d-lg-table-cell small text-end text-nowrap"><?= $ms_text($row['ai_usage_ms']) ?></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php
$empty_state = [
'thing' => 'AI requests',
'icon' => 'fa-chart-bar',
'views' => ['view'],
'view' => $view !== '' ? ['icon' => $views[$view]['icon'], 'title' => 'None ' . ($view === 'test' ? 'tried yet' : 'yet'), 'text' => escapeHtml($views[$view]['title']) . ' - they show here.'] : null,
'text' => 'Each time an agent rewords a reply, summarizes a ticket or drafts a document, it shows here - who, for what, the model and the tokens it took. ' . ($retention['audit'] ? 'Kept for ' . escapeHtml(appLogRetentionText($retention['audit'])) . ', as the audit log is.' : 'Kept as long as the audit log is.'),
];
?>
<?php require_once "../includes/filter_footer.php"; ?>
</div>
<?php
require_once "../includes/footer.php";

View File

@@ -0,0 +1,67 @@
<?php
/*
* ITFlow - Database update to version 3.3.8 (from 3.3.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
$itflow_column = function ($table, $column) use ($mysqli) {
$result = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table' AND COLUMN_NAME = '$column' LIMIT 1");
return $result && mysqli_num_rows($result) > 0;
};
// AI providers - which kind each is (how it is called and its models listed),
// and whether passwords and keys are taken out of what is sent to it. Room for
// the longer keys and Azure's longer addresses.
if (!$itflow_column('ai_providers', 'ai_provider_kind')) {
mysqli_query($mysqli, "ALTER TABLE `ai_providers` ADD `ai_provider_kind` varchar(50) NOT NULL DEFAULT 'custom' AFTER `ai_provider_name`");
// Those set up already, by where they point
foreach ([
'api.openai.com' => 'openai', 'openai.azure.com' => 'azure', 'api.anthropic.com' => 'anthropic', 'generativelanguage.googleapis.com' => 'gemini',
'api.mistral.ai' => 'mistral', 'api.groq.com' => 'groq', 'openrouter.ai' => 'openrouter', ':11434' => 'ollama', ':1234' => 'lmstudio',
] as $itflow_host => $itflow_kind) {
mysqli_query($mysqli, "UPDATE `ai_providers` SET `ai_provider_kind` = '$itflow_kind' WHERE `ai_provider_kind` = 'custom' AND `ai_provider_api_url` LIKE '%$itflow_host%'");
}
}
if (!$itflow_column('ai_providers', 'ai_provider_redact')) {
mysqli_query($mysqli, "ALTER TABLE `ai_providers` ADD `ai_provider_redact` tinyint(1) NOT NULL DEFAULT 1 AFTER `ai_provider_api_key`");
}
mysqli_query($mysqli, "ALTER TABLE `ai_providers` MODIFY `ai_provider_api_url` varchar(500) NOT NULL, MODIFY `ai_provider_api_key` varchar(500) DEFAULT NULL");
// Each AI request - what it was for, who asked, the model, tokens and how long it took
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `ai_usage` (
`ai_usage_id` int(11) NOT NULL AUTO_INCREMENT,
`ai_usage_feature` varchar(50) NOT NULL,
`ai_usage_use_case` varchar(50) DEFAULT NULL,
`ai_usage_provider_id` int(11) NOT NULL DEFAULT 0,
`ai_usage_provider_name` varchar(200) DEFAULT NULL,
`ai_usage_model_id` int(11) NOT NULL DEFAULT 0,
`ai_usage_model` varchar(200) DEFAULT NULL,
`ai_usage_user_id` int(11) NOT NULL DEFAULT 0,
`ai_usage_client_id` int(11) NOT NULL DEFAULT 0,
`ai_usage_tokens_in` int(11) NOT NULL DEFAULT 0,
`ai_usage_tokens_out` int(11) NOT NULL DEFAULT 0,
`ai_usage_ms` int(11) NOT NULL DEFAULT 0,
`ai_usage_redacted` int(11) NOT NULL DEFAULT 0,
`ai_usage_ok` tinyint(1) NOT NULL DEFAULT 1,
`ai_usage_error` varchar(255) DEFAULT NULL,
`ai_usage_created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ai_usage_id`),
KEY `ai_usage_created_at` (`ai_usage_created_at`),
KEY `ai_usage_provider_id` (`ai_usage_provider_id`),
KEY `ai_usage_model_id` (`ai_usage_model_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// Using AI is a permission of its own - every role keeps it, as anyone could use it before
$itflow_module = mysqli_fetch_row(mysqli_query($mysqli, "SELECT module_id FROM modules WHERE module_name = 'module_ai' LIMIT 1"));
if (!$itflow_module) {
mysqli_query($mysqli, "INSERT INTO modules SET module_name = 'module_ai', module_description = 'Using AI - rewording, summaries and drafts'");
$itflow_module_id = intval(mysqli_insert_id($mysqli));
mysqli_query($mysqli, "INSERT INTO user_role_permissions (user_role_id, module_id, user_role_permission_level)
SELECT role_id, $itflow_module_id, 1 FROM user_roles WHERE role_is_admin = 0");
}
unset($itflow_column, $itflow_host, $itflow_kind, $itflow_module, $itflow_module_id);

View File

@@ -102,7 +102,7 @@
<?php } ?>
<li class="nav-item">
<a href="/admin/ai_providers.php"
class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['ai_providers.php', 'ai_models.php']) ? 'active' : '') ?>">
class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['ai_providers.php', 'ai_models.php', 'ai_usage.php']) ? 'active' : '') ?>">
<i class="nav-icon fas fa-robot"></i>
<p>AI Providers</p>
</a>

View File

@@ -1,78 +0,0 @@
<?php
require_once '../../includes/modal_header.php';
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-robot me-2"></i>Add AI Model</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="mb-3">
<label>Provider <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<select class="form-select select2" name="provider" required>
<option value="">- Select an AI Provider -</option>
<?php
$sql_ai_providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name FROM ai_providers");
while ($row = mysqli_fetch_assoc($sql_ai_providers)) {
$ai_provider_id = intval($row['ai_provider_id']);
$ai_provider_name = escapeHtml($row['ai_provider_name']);
?>
<option value="<?= $ai_provider_id ?>"><?= $ai_provider_name ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="mb-3">
<label>Model Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<input type="text" class="form-control" name="model" placeholder="ex gpt-4" maxlength="200">
</div>
</div>
<div class="mb-3">
<label>Use Case <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-th-list"></i></span>
<select class="form-select select2" name="use_case">
<option>General</option>
<option>Tickets</option>
<option>Documentation</option>
</select>
</div>
</div>
<div class="mb-3">
<label>Temperature</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
<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="mb-3">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_ai_model" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Create</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,91 +1,325 @@
<?php
/*
* Admin > AI Models - one model's window: Add (no id - ?provider= picks its
* provider) or Edit. The model's name, suggested from what its provider lists;
* what it is for, saying what that use case uses now; its temperature; and
* the company's instructions that go with every request it handles. Try it
* sends it a one-line request as it is in the window, unsaved. Save asks first
* (check_ai_model, admin/post/ai_models.php), so what is wrong shows beside
* each field.
*/
require_once '../../includes/modal_header.php';
$model_id = intval($_GET['id']);
$model_id = intval($_GET['id'] ?? 0);
$row = $model_id ? aiModelRow($model_id) : null;
if ($model_id && !$row) {
echo json_encode(['error' => 'That AI model no longer exists.']);
exit;
}
$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");
$provider_id = $row ? intval($row['ai_model_ai_provider_id']) : intval($_GET['provider'] ?? 0);
$providers = [];
$sql = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name, ai_provider_kind FROM ai_providers ORDER BY ai_provider_name");
while ($provider = mysqli_fetch_assoc($sql)) {
$providers[intval($provider['ai_provider_id'])] = $provider;
}
if (!isset($providers[$provider_id])) {
$provider_id = count($providers) === 1 ? array_key_first($providers) : 0;
}
$use_cases = aiUseCases();
$map = aiUseCaseMap(['model' => $model_id])['map'];
// A new one goes where there is nothing yet - General first
$use_case = $row ? (string) $row['ai_model_use_case'] : 'General';
if (!$row) {
foreach ($use_cases as $case => $about) {
if (!$map[$case]['own']) {
$use_case = $case;
break;
}
}
}
// What each use case has now, without this one - so picking one says what it takes over, or that it won't be used
$taken = [];
foreach ($map as $case => $lands) {
$taken[$case] = $lands['own'] ? ['name' => $lands['model']['ai_model_name'], 'provider' => $lands['model']['ai_provider_name'], 'first' => intval($lands['model']['ai_model_id']) < ($model_id ?: PHP_INT_MAX)] : null;
}
$temperature = $row && $row['ai_model_temperature'] !== null ? rtrim(rtrim(number_format(floatval($row['ai_model_temperature']), 2, '.', ''), '0'), '.') : '';
$row = mysqli_fetch_assoc($sql);
$ai_model_ai_provider_id = intval($row['ai_model_ai_provider_id']);
$model_id = intval($row['ai_model_id']);
$model_name = escapeHtml($row['ai_model_name']);
$use_case = escapeHtml($row['ai_model_use_case']);
$temperature = escapeHtml($row['ai_model_temperature']);
$prompt = escapeHtml($row['ai_model_prompt']);
$error_for = function ($field) {
return '<div class="invalid-feedback" data-error-for="' . $field . '"></div>';
};
// Generate the HTML form content using output buffering.
ob_start();
?>
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-robot me-2"></i>Editing: <strong><?= $model_name ?></strong></h5>
<h5 class="modal-title"><i class="fa fa-fw fa-brain me-2"></i><?= $row ? '<strong>' . escapeHtml($row['ai_model_name']) . '</strong>' : 'Add AI Model' ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<form action="post.php" method="post" autocomplete="off" novalidate data-ai-model-form data-taken="<?= escapeHtml(json_encode($taken)) ?>" data-csrf="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="model_id" value="<?= $model_id ?>">
<div class="modal-body">
<div class="mb-3">
<label>Provider <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<select class="form-select select2" name="provider" required>
<option value="">- Select an AI Provider -</option>
<?php
$sql_ai_providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name FROM ai_providers");
while ($row = mysqli_fetch_assoc($sql_ai_providers)) {
$ai_provider_id = intval($row['ai_provider_id']);
$ai_provider_name = escapeHtml($row['ai_provider_name']);
<?php if (!$providers) { ?>
<div class="alert alert-info py-2 small"><i class="fas fa-fw fa-info-circle me-1"></i>Add an AI provider first - a model is on one.</div>
<?php } ?>
?>
<option <?php if ($ai_provider_id = $ai_model_ai_provider_id) { echo "selected"; } ?> value="<?= $ai_provider_id ?>"><?= $ai_provider_name ?></option>
<div class="mb-3">
<label class="form-label" for="aiModelProvider">Provider <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<select class="form-select select2" name="provider" id="aiModelProvider" data-ai-model-provider>
<option value="">- Pick a provider -</option>
<?php foreach ($providers as $id => $provider) { ?>
<option value="<?= $id ?>" <?= $id === $provider_id ? 'selected' : '' ?><?= selectOptionData(['icon' => aiProviderIcon($provider['ai_provider_kind']), 'description' => aiProviderKind($provider['ai_provider_kind'])['name'], 'example' => aiProviderKind($provider['ai_provider_kind'])['example']]) ?>><?= escapeHtml($provider['ai_provider_name']) ?></option>
<?php } ?>
</select>
</div>
<?= $error_for('provider') ?>
</div>
<div class="mb-3">
<label>Model Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<input type="text" class="form-control" name="model" value="<?= $model_name ?>" placeholder="ex gpt-4" maxlength="200">
<label class="form-label" for="aiModelName">Model <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-brain"></i></span>
<input type="text" class="form-control font-monospace" name="model" id="aiModelName" maxlength="200" spellcheck="false" list="aiModelList" value="<?= escapeHtml($row['ai_model_name'] ?? '') ?>" placeholder="As the provider names it" data-ai-model-name>
</div>
<?= $error_for('model') ?>
<datalist id="aiModelList" data-ai-model-list></datalist>
<div class="form-text" data-ai-model-hint>As the provider names it.</div>
</div>
<div class="mb-3">
<label>Use Case <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-th-list"></i></span>
<select class="form-select select2" name="use_case">
<option <?php if ($use_case == 'General') { echo "selected"; } ?>>General</option>
<option <?php if ($use_case == 'Tickets') { echo "selected"; } ?>>Tickets</option>
<option <?php if ($use_case == 'Documentation') { echo "selected"; } ?>>Documentation</option>
<label class="form-label" for="aiModelUseCase">For <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-th-list"></i></span>
<select class="form-select select2" name="use_case" id="aiModelUseCase" data-ai-model-use-case>
<?php foreach ($use_cases as $case => $about) { ?>
<option value="<?= escapeHtml($case) ?>" <?= $case === $use_case ? 'selected' : '' ?><?= selectOptionData(['description' => $about]) ?>><?= escapeHtml($case) ?></option>
<?php } ?>
<?php if ($row && !isset($use_cases[$use_case])) { ?>
<option value="<?= escapeHtml($use_case) ?>" selected><?= escapeHtml($use_case) ?> (not used)</option>
<?php } ?>
</select>
</div>
<?= $error_for('use_case') ?>
<div class="form-text" data-ai-use-case-note></div>
</div>
<div class="mb-3">
<label>Temperature</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
<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>
<label class="form-label" for="aiModelPrompt">Instructions</label>
<textarea class="form-control" rows="4" name="prompt" id="aiModelPrompt" maxlength="4000" placeholder="Write in US English, friendly and plain. We are PittPC. Never promise a time a fix will be done."><?= escapeHtml($row['ai_model_prompt'] ?? '') ?></textarea>
<?= $error_for('prompt') ?>
<div class="form-text">Optional - your house style, sent with everything this model is asked, after what each feature asks of it.</div>
</div>
<div class="mb-3">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"><?= $prompt ?></textarea>
<label class="form-label" for="aiModelTemperature">Temperature</label>
<div class="input-group has-validation" style="max-width: 16rem;">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
<input type="text" class="form-control" name="temperature" id="aiModelTemperature" inputmode="decimal" maxlength="5" value="<?= escapeHtml($temperature) ?>" placeholder="The model's own">
</div>
<?= $error_for('temperature') ?>
<div class="form-text">Leave it empty - newer models, Claude's and OpenAI's reasoning ones among them, turn away any other. Lower is plainer, higher more varied.</div>
</div>
<div class="d-flex align-items-start gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-ai-try title="Sends it a one-line request, as it is here - recorded in AI Usage as a test"><i class="fas fa-fw fa-paper-plane me-1"></i>Try it</button>
<div class="small pt-1 min-w-0" data-ai-try-result></div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="edit_ai_model" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Save</button>
<button type="submit" name="<?= $row ? 'edit_ai_model' : 'add_ai_model' ?>" class="btn btn-primary text-bold" data-ai-save <?= $providers ? '' : 'disabled' ?>>
<i class="fa fa-check me-2"></i><?= $row ? 'Save' : 'Add' ?>
</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
<script>
// The provider's models suggested, what a use case has now said, Try it, and Save asked first
(function () {
var form = document.querySelector('[data-ai-model-form]:not([data-ready])');
if (!form) {
return;
}
form.setAttribute('data-ready', '1');
var taken = JSON.parse(form.getAttribute('data-taken'));
var provider = form.querySelector('[data-ai-model-provider]');
var modelName = form.querySelector('[data-ai-model-name]');
var modelList = form.querySelector('[data-ai-model-list]');
var modelHint = form.querySelector('[data-ai-model-hint]');
var useCase = form.querySelector('[data-ai-model-use-case]');
var useCaseNote = form.querySelector('[data-ai-use-case-note]');
var tryButton = form.querySelector('[data-ai-try]');
var tryResult = form.querySelector('[data-ai-try-result]');
var save = form.querySelector('[data-ai-save]');
var checked = false;
function escapeText(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showUseCase() {
var now = taken[useCase.value];
if (!now) {
useCaseNote.textContent = useCase.value === 'General' ? 'Everything without a model of its own uses it.' : 'Nothing else is for ' + useCase.value + ' - this one will be used for it, instead of General.';
useCaseNote.className = 'form-text';
} else if (now.first) {
useCaseNote.textContent = now.name + ' on ' + now.provider + ' is for ' + useCase.value + ' already, and is the one used - this one won\'t be, while it is.';
useCaseNote.className = 'form-text text-warning-emphasis';
} else {
useCaseNote.textContent = 'This one is used for ' + useCase.value + ' - ' + now.name + ' is for it too, but was added after.';
useCaseNote.className = 'form-text';
}
}
useCase.addEventListener('change', showUseCase);
showUseCase();
// What the provider has - only the answer for the provider picked last counts
var asking = 0;
function loadModels() {
var asked = ++asking;
modelList.innerHTML = '';
var option = provider.options[provider.selectedIndex];
var example = option ? option.getAttribute('data-example') : '';
modelName.placeholder = example ? 'Like ' + example : 'As the provider names it';
if (!provider.value) {
modelHint.textContent = 'As the provider names it.';
return;
}
modelHint.textContent = 'Asking the provider what it has...';
var data = new FormData();
data.append('csrf_token', form.getAttribute('data-csrf'));
data.append('ai_provider_models', provider.value);
fetch(form.getAttribute('action'), { method: 'POST', body: data, credentials: 'same-origin' })
.then(function (response) { return response.json(); })
.then(function (answer) {
if (asked !== asking) {
return;
}
modelList.innerHTML = '';
(answer.models || []).forEach(function (model) {
var item = document.createElement('option');
item.value = model;
modelList.appendChild(item);
});
if (!answer.ok) {
modelHint.textContent = 'As the provider names it - it couldn\'t be asked what it has: ' + answer.error;
} else if (!answer.listed) {
modelHint.textContent = 'Your deployment\'s name, as it is in Azure.';
} else {
modelHint.textContent = 'As the provider names it - ' + answer.models.length + ' to pick from as you type.';
}
modelHint.setAttribute('data-ai-models-loaded', String((answer.models || []).length));
})
.catch(function () {
modelHint.textContent = 'As the provider names it.';
});
}
provider.addEventListener('change', loadModels);
loadModels();
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); });
form.querySelectorAll('[data-error-for]').forEach(function (el) {
el.textContent = '';
el.classList.remove('d-block');
});
}
function showErrors(errors) {
var first = null;
Object.keys(errors).forEach(function (field) {
var input = form.querySelector('[name="' + field + '"]');
var box = form.querySelector('[data-error-for="' + field + '"]');
if (input) {
input.classList.add('is-invalid');
first = first || input;
}
if (box) {
box.textContent = errors[field];
box.classList.add('d-block');
}
});
if (first && first.offsetParent !== null) {
first.focus();
}
}
function ask(tryIt) {
var data = new FormData(form);
data.append('check_ai_model', '1');
if (tryIt) {
data.append('try', '1');
}
return fetch(form.getAttribute('action'), { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (response) { return response.json(); });
}
tryButton.addEventListener('click', function () {
var label = tryButton.innerHTML;
tryButton.disabled = true;
tryButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Asking';
tryResult.innerHTML = '';
ask(true)
.then(function (answer) {
clearErrors();
if (answer.ok) {
tryResult.innerHTML = '<div class="text-success-emphasis" data-ai-try-ok><i class="fas fa-fw fa-check-circle me-1"></i>It answered in ' + (answer.ms / 1000).toFixed(1) + 's:</div>'
+ '<div class="fst-italic text-body-secondary text-break">' + escapeText(answer.reply) + '</div>';
} else {
showErrors(answer.errors || {});
tryResult.innerHTML = '<span class="text-danger-emphasis" data-ai-try-failed><i class="fas fa-fw fa-times-circle me-1"></i>Not yet - see above.</span>';
}
})
.catch(function () {
tryResult.innerHTML = '<span class="text-danger-emphasis">Couldn\'t try it - try again.</span>';
})
.finally(function () {
tryButton.disabled = false;
tryButton.innerHTML = label;
});
});
form.addEventListener('submit', function (e) {
if (checked) {
return;
}
e.preventDefault();
var label = save.innerHTML;
save.disabled = true;
save.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Checking';
ask(false)
.then(function (answer) {
clearErrors();
save.disabled = false;
if (answer.ok) {
checked = true;
save.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving';
form.requestSubmit(save);
save.disabled = true;
return;
}
save.innerHTML = label;
showErrors(answer.errors || {});
})
.catch(function () {
checked = true;
save.disabled = false;
save.innerHTML = label;
form.requestSubmit(save);
});
});
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,50 +0,0 @@
<?php
require_once '../../includes/modal_header.php';
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-robot me-2"></i>New AI Provider</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="mb-3">
<label>Provider Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<input type="text" class="form-control" name="provider" placeholder="ex OpenAI" maxlength="200">
</div>
</div>
<div class="mb-3">
<label>URL <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-globe"></i></span>
<input type="url" class="form-control" name="url" placeholder="ex https://ai.company.ext/api" maxlength="200">
</div>
</div>
<div class="mb-3">
<label>API Key</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
<input type="text" class="form-control" name="api_key" placeholder="Enter API key here" maxlength="200">
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_ai_provider" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Create</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -1,61 +1,344 @@
<?php
/*
* Admin > AI Providers - one provider's window: Add (no id) or Edit. Its kind
* fills in its address and says where its key is made; the key is never put
* back into the page, so left empty it stays. Test asks the provider for its
* models, which shows they work and suggests the model to start with. Save
* asks first (check_ai_provider, admin/post/ai_provider.php): what is wrong
* shows beside each field and the window stays open - a provider that doesn't
* answer can still be saved, once said.
*/
require_once '../../includes/modal_header.php';
$provider_id = intval($_GET['id']);
$provider_id = intval($_GET['id'] ?? 0);
$row = $provider_id ? aiProviderRow($provider_id) : null;
if ($provider_id && !$row) {
echo json_encode(['error' => 'That AI provider no longer exists.']);
exit;
}
$sql = mysqli_query($mysqli, "SELECT ai_provider_api_key, ai_provider_api_url, ai_provider_name FROM ai_providers WHERE ai_provider_id = $provider_id LIMIT 1");
$kinds = aiProviderKinds();
$kind_key = $row ? (string) $row['ai_provider_kind'] : 'openai';
$kind_key = isset($kinds[$kind_key]) ? $kind_key : 'custom';
$kind = $kinds[$kind_key];
$has_key = $row && trim((string) $row['ai_provider_api_key']) !== '';
$redact = $row ? intval($row['ai_provider_redact']) === 1 : true;
// Adding the first, the model to start with comes along - for everything
$offer_first_model = !$row && !getAiModel('General');
$row = mysqli_fetch_assoc($sql);
$provider_name = escapeHtml($row['ai_provider_name']);
$url = escapeHtml($row['ai_provider_api_url']);
$key = escapeHtml($row['ai_provider_api_key']);
// What the window's script needs of each kind
$kinds_js = [];
foreach ($kinds as $key => $info) {
$kinds_js[$key] = ['name' => $info['name'], 'url' => $info['url'], 'key' => $info['key'], 'keysUrl' => $info['keys_url'], 'local' => $info['local'], 'example' => $info['example']];
}
// Where what is wrong with a field shows
$error_for = function ($field) {
return '<div class="invalid-feedback" data-error-for="' . $field . '"></div>';
};
// Generate the HTML form content using output buffering.
ob_start();
?>
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-robot me-2"></i>Editing: <strong><?= $provider_name ?></strong></h5>
<h5 class="modal-title"><i class="fa fa-fw fa-robot me-2"></i><?= $row ? '<strong>' . escapeHtml($row['ai_provider_name']) . '</strong>' : 'Add AI Provider' ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<form action="post.php" method="post" autocomplete="off" novalidate data-ai-provider-form data-kinds="<?= escapeHtml(json_encode($kinds_js)) ?>" data-has-key="<?= $has_key ? '1' : '0' ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?= $provider_id ?>">
<input type="hidden" name="save_anyway" value="" data-save-anyway>
<div class="modal-body">
<div class="mb-3">
<label>Provider Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
<input type="text" class="form-control" name="provider" value="<?= $provider_name ?>" placeholder="ex OpenAI" maxlength="200">
<label class="form-label" for="aiProviderKind">Kind <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-shapes"></i></span>
<select class="form-select select2" name="kind" id="aiProviderKind" data-ai-kind>
<?php foreach ($kinds as $key => $info) { ?>
<option value="<?= $key ?>" <?= $key === $kind_key ? 'selected' : '' ?><?= selectOptionData(['icon' => aiProviderIcon($key), 'description' => $info['local'] ? 'Runs on your own network - nothing leaves it' : ($key === 'custom' ? 'Any other service that speaks the OpenAI API' : (string) parse_url($info['url'], PHP_URL_HOST))]) ?>><?= escapeHtml($info['name']) ?></option>
<?php } ?>
</select>
</div>
<?= $error_for('kind') ?>
</div>
<div class="mb-3">
<label>URL <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-globe"></i></span>
<input type="url" class="form-control" name="url" value="<?= $url ?>" placeholder="ex https://ai.company.ext/api" maxlength="200">
<label class="form-label" for="aiProviderName">Name <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<input type="text" class="form-control" name="name" id="aiProviderName" maxlength="200" value="<?= escapeHtml($row ? $row['ai_provider_name'] : $kind['name']) ?>" placeholder="What you call it">
</div>
<?= $error_for('name') ?>
</div>
<div class="mb-3">
<label>API Key</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
<input type="text" class="form-control" name="api_key" value="<?= $key ?>" placeholder="Enter API key here" maxlength="200">
<label class="form-label" for="aiProviderUrl">Address <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-globe"></i></span>
<input type="url" class="form-control font-monospace small" name="url" id="aiProviderUrl" maxlength="500" spellcheck="false" value="<?= escapeHtml($row ? $row['ai_provider_api_url'] : $kind['url']) ?>" placeholder="https://.../v1/chat/completions">
</div>
<?= $error_for('url') ?>
<div class="form-text">Its chat completions address - filled in for the kind picked. One ending /v1 has /chat/completions put on.</div>
</div>
<div class="mb-3">
<label class="form-label" for="aiProviderKey">API key <strong class="text-danger <?= $kind['key'] && !$has_key ? '' : 'd-none' ?>" data-ai-key-required>*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
<input type="password" class="form-control font-monospace" name="api_key" id="aiProviderKey" maxlength="500" spellcheck="false" autocomplete="new-password"
placeholder="<?= $has_key ? 'Saved - type a new one to replace it' : 'Paste its API key' ?>">
</div>
<?= $error_for('api_key') ?>
<div class="form-text" data-ai-key-hint></div>
<?php if ($has_key) { ?>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="clear_key" value="1" id="aiProviderClearKey">
<label class="form-check-label small" for="aiProviderClearKey">Remove the saved key</label>
</div>
<?php } ?>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" name="redact" value="1" id="aiProviderRedact" <?= $redact ? 'checked' : '' ?>>
<label class="form-check-label" for="aiProviderRedact">Take passwords and keys out of what is sent</label>
</div>
<div class="form-text ps-5">Passwords, PINs, API keys, private keys, 2FA links and card numbers in a reply or ticket go as placeholders, and are put back in the answer - the provider never sees them.</div>
</div>
<?php if ($offer_first_model) { ?>
<div class="mb-3">
<label class="form-label" for="aiProviderFirstModel">Model to use</label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-brain"></i></span>
<input type="text" class="form-control font-monospace" name="first_model" id="aiProviderFirstModel" maxlength="200" spellcheck="false" list="aiProviderModelList" placeholder="<?= escapeHtml($kind['example']) ?>" data-ai-first-model>
</div>
<?= $error_for('first_model') ?>
<div class="form-text">For everything - Reword, ticket summaries and document drafts. Test lists the ones it has; more can be added in AI Models.</div>
</div>
<?php } ?>
<datalist id="aiProviderModelList" data-ai-model-list></datalist>
<div class="d-flex align-items-start gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-ai-test><i class="fas fa-fw fa-plug me-1"></i>Test</button>
<div class="small pt-1" data-ai-test-result></div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="edit_ai_provider" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Save</button>
<button type="submit" name="<?= $row ? 'edit_ai_provider' : 'add_ai_provider' ?>" class="btn btn-primary text-bold" data-ai-save>
<i class="fa fa-check me-2"></i><?= $row ? 'Save' : 'Add' ?>
</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
<script>
// Its kind fills in the address and name, and says about the key. Test and Save ask first, so what is wrong shows here
(function () {
var form = document.querySelector('[data-ai-provider-form]:not([data-ready])');
if (!form) {
return;
}
form.setAttribute('data-ready', '1');
var kinds = JSON.parse(form.getAttribute('data-kinds'));
var hasKey = form.getAttribute('data-has-key') === '1';
var kindSelect = form.querySelector('[data-ai-kind]');
var name = form.querySelector('[name="name"]');
var url = form.querySelector('[name="url"]');
var keyHint = form.querySelector('[data-ai-key-hint]');
var keyRequired = form.querySelector('[data-ai-key-required]');
var save = form.querySelector('[data-ai-save]');
var saveLabel = save.innerHTML;
var saveAnyway = form.querySelector('[data-save-anyway]');
var testButton = form.querySelector('[data-ai-test]');
var testResult = form.querySelector('[data-ai-test-result]');
var modelList = form.querySelector('[data-ai-model-list]');
var firstModel = form.querySelector('[data-ai-first-model]');
var kind = kindSelect.value;
var checked = false;
function escapeText(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function isPreset(field, value) {
return value === '' || Object.keys(kinds).some(function (key) { return kinds[key][field] === value; });
}
function showKind() {
var info = kinds[kind];
keyRequired.classList.toggle('d-none', !info.key || hasKey);
if (info.local) {
keyHint.textContent = info.name + ' runs on your own network and needs no key unless you set one. ITFlow has to be able to reach it - localhost is the ITFlow server itself.';
} else if (info.keysUrl) {
keyHint.innerHTML = 'Made in <a href="' + escapeText(info.keysUrl) + '" target="_blank" rel="noopener noreferrer">' + escapeText(info.name) + '\'s console <i class="fas fa-external-link-alt fa-xs"></i></a> - give ITFlow a key of its own, so it can be turned off on its own.';
} else {
keyHint.textContent = 'If it needs one - sent as a bearer token.';
}
if (firstModel) {
firstModel.placeholder = info.example;
}
}
// Anything changed - asked again before it is saved
function reset() {
checked = false;
saveAnyway.value = '';
save.innerHTML = saveLabel;
save.classList.remove('btn-warning');
save.classList.add('btn-primary');
}
form.querySelectorAll('input:not([type=hidden])').forEach(function (el) {
el.addEventListener('input', reset);
});
kindSelect.addEventListener('change', function () {
var was = kinds[kind];
kind = kindSelect.value;
var info = kinds[kind];
// What the last kind filled in follows the new one; what was typed stays. Another kind is named by you
if (isPreset('name', name.value.trim())) {
name.value = kind === 'custom' ? '' : info.name;
}
if (isPreset('url', url.value.trim()) || url.value.trim() === was.url) {
url.value = info.url;
}
showKind();
reset();
});
showKind();
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); });
form.querySelectorAll('[data-error-for]').forEach(function (el) {
el.textContent = '';
el.classList.remove('d-block');
});
}
function showErrors(errors) {
var first = null;
Object.keys(errors).forEach(function (field) {
var input = form.querySelector('[name="' + field + '"]');
var box = form.querySelector('[data-error-for="' + field + '"]');
if (input) {
input.classList.add('is-invalid');
first = first || input;
}
if (box) {
box.textContent = errors[field];
box.classList.add('d-block');
}
});
if (first && first.offsetParent !== null) {
first.focus();
}
}
function showModels(answer) {
modelList.innerHTML = '';
(answer.models || []).forEach(function (model) {
var option = document.createElement('option');
option.value = model;
modelList.appendChild(option);
});
// As it will be saved - /chat/completions put on
if (answer.url && answer.url !== url.value.trim()) {
url.value = answer.url;
}
var count = (answer.models || []).length;
if (!answer.listed) {
testResult.innerHTML = '<span class="text-body-secondary" data-ai-test-unlisted><i class="fas fa-fw fa-info-circle me-1"></i>' + escapeText(kinds[kind].name) + ' doesn\'t list its models - use Try it on its model once it is added.</span>';
} else {
testResult.innerHTML = '<span class="text-success-emphasis" data-ai-test-ok><i class="fas fa-fw fa-check-circle me-1"></i>It answered - ' + count + ' model' + (count === 1 ? '' : 's') + ' there.</span>'
+ (firstModel && count ? ' <span class="text-body-secondary">Pick the one to use above.</span>' : '');
}
}
function ask(test) {
var data = new FormData(form);
data.append('check_ai_provider', '1');
if (test) {
data.append('test', '1');
}
return fetch(form.getAttribute('action'), { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (response) { return response.json(); });
}
testButton.addEventListener('click', function () {
var label = testButton.innerHTML;
testButton.disabled = true;
testButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Testing';
testResult.innerHTML = '';
ask(true)
.then(function (answer) {
clearErrors();
if (answer.ok) {
showModels(answer);
} else {
showErrors(answer.errors || {});
testResult.innerHTML = '<span class="text-danger-emphasis" data-ai-test-failed><i class="fas fa-fw fa-times-circle me-1"></i>Not yet - see above.</span>';
}
})
.catch(function () {
testResult.innerHTML = '<span class="text-danger-emphasis">Couldn\'t test it - try again.</span>';
})
.finally(function () {
testButton.disabled = false;
testButton.innerHTML = label;
});
});
form.addEventListener('submit', function (e) {
// Asked already - or said it doesn't answer, and Save anyway pressed: it goes
if (checked || saveAnyway.value === '1') {
checked = true;
return;
}
e.preventDefault();
save.disabled = true;
save.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Checking';
ask(false)
.then(function (answer) {
clearErrors();
save.disabled = false;
if (answer.ok) {
if (answer.asked) {
showModels(answer);
}
checked = true;
save.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving';
form.requestSubmit(save);
save.disabled = true;
return;
}
save.innerHTML = saveLabel;
showErrors(answer.errors || {});
// Only that it didn't answer - it can be saved anyway
if (answer.soft) {
saveAnyway.value = '1';
save.innerHTML = '<i class="fa fa-check me-2"></i>Save anyway';
save.classList.remove('btn-primary');
save.classList.add('btn-warning');
}
})
.catch(function () {
// Couldn't ask - send it as it is, and the save says what is wrong
checked = true;
save.disabled = false;
save.innerHTML = saveLabel;
form.requestSubmit(save);
});
});
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -15,7 +15,8 @@
isset($mysqli) || die("Direct file access is not allowed");
$t = $template;
$ai_model = getAiModel('Documentation');
// Only offered when a model is set up for it and the role may use AI
$ai_model = aiReady('Documentation') ? getAiModel('Documentation') : null;
?>

View File

@@ -1,77 +1,118 @@
<?php
/*
* ITFlow - GET/POST request handler for AI Models ('ai_models')
* ITFlow - Admin > AI Models (admin/ai_models.php): adding one, changing it,
* trying it and deleting it.
*
* The window asks first (check_ai_model, JSON) and says what is wrong beside
* each field; with 'try' it also sends the model a one-line request, as it is
* in the window - unsaved - and says what came back, how long it took and the
* tokens it used (recorded in AI Usage as a test). What changed is in the
* audit log, from what to what - the instructions only as changed.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['add_ai_model'])) {
if (isset($_POST['check_ai_model']) || isset($_POST['add_ai_model']) || isset($_POST['edit_ai_model'])) {
validateCSRFToken();
$provider_id = intval($_POST['provider']);
$model = escapeSql($_POST['model']);
$prompt = escapeSql($_POST['prompt']);
$use_case = escapeSql($_POST['use_case']);
$checking = isset($_POST['check_ai_model']);
$model_id = intval($_POST['model_id'] ?? 0);
$before = $model_id ? aiModelRow($model_id) : null;
$answer = function ($ok, $errors, $extra = []) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'errors' => (object) $errors] + $extra);
exit;
};
// 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']);
if ($model_id && !$before) {
if ($checking) {
$answer(false, ['model' => 'That model no longer exists - close this and look again.']);
}
flashAlert('That AI model no longer exists.', 'error');
redirect('ai_models.php');
}
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");
[$after, $errors, $provider] = aiModelCheck($_POST);
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');
if ($checking) {
$tried = [];
if (!$errors && !empty($_POST['try'])) {
// As it is in the window, on its provider
$model = $provider + [
'ai_model_id' => $model_id,
'ai_model_name' => $after['ai_model_name'],
'ai_model_use_case' => $after['ai_model_use_case'],
'ai_model_temperature' => $after['ai_model_temperature'],
'ai_model_prompt' => $after['ai_model_prompt'],
];
$started = microtime(true);
$result = callAiApi($model, [
['role' => 'system', 'content' => 'You are checking that you are set up and working. Answer in one short, friendly sentence.'],
['role' => 'user', 'content' => 'Say hello, and which model you are.'],
], ['feature' => 'test']);
$ms = intval(round((microtime(true) - $started) * 1000));
if ($result['ok']) {
$tried = ['reply' => mb_substr(trim(strip_tags($result['content'])), 0, 400), 'ms' => $ms];
} else {
$errors['model'] = $result['error'];
}
}
$answer(!$errors, $errors, $tried);
}
// Only reached without the window's script - say the first thing wrong
if ($errors) {
flashAlert('Not saved - ' . escapeHtml(reset($errors)), 'error');
redirect();
}
logAudit("AI Model", "Create", "$session_name created AI Model $model");
$name = $after['ai_model_name'];
$use_case = $after['ai_model_use_case'];
flashAlert("AI Model <strong>$model</strong> created");
redirect();
}
if (isset($_POST['edit_ai_model'])) {
validateCSRFToken();
$model_id = intval($_POST['model_id']);
$model = escapeSql($_POST['model']);
$prompt = escapeSql($_POST['prompt']);
$use_case = escapeSql($_POST['use_case']);
// 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");
flashAlert("AI Model <strong>$model</strong> edited");
if (!$before) {
// A second one for a use case isn't used - the first one is - so it is said
$already = getAiModel($use_case);
$model_id = aiModelWrite($after);
logAudit("AI Model", "Create", "$session_name added AI model " . escapeSql("$name on {$provider['ai_provider_name']}, for $use_case"), 0, $model_id);
if ($already && $already['ai_model_use_case'] === $use_case) {
flashAlert('<strong>' . escapeHtml($name) . '</strong> added - but <strong>' . escapeHtml($already['ai_model_name']) . "</strong> is for $use_case already and is the one used. Delete it, or set it to something else, to use this one", 'warning');
} else {
flashAlert('<strong>' . escapeHtml($name) . "</strong> added - it is used for " . ($use_case === 'General' ? 'everything without a model of its own' : $use_case));
}
redirect('ai_models.php');
}
$lines = aiModelChangeLines($before, $after);
if (!$lines) {
flashAlert('Nothing changed on <strong>' . escapeHtml($name) . '</strong>', 'info');
redirect();
}
aiModelWrite($after, $model_id);
logAudit("AI Model", "Edit", "$session_name changed AI model " . escapeSql("$name: " . implode('; ', $lines)), 0, $model_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> saved - ' . escapeHtml(count($lines) === 1 ? $lines[0] : count($lines) . ' changes'));
redirect();
}
// Delete a model - what it was for falls back on General, or stops when it was General
if (isset($_GET['delete_ai_model'])) {
validateCSRFToken();
$model_id = intval($_GET['delete_ai_model']);
$row = aiModelRow($_GET['delete_ai_model']);
if (!$row) {
flashAlert('That AI model no longer exists.', 'error');
redirect('ai_models.php');
}
$model_id = intval($row['ai_model_id']);
$name = $row['ai_model_name'];
$model_name = escapeSql(getFieldById('ai_models', $model_id, 'ai_model_name'));
mysqli_query($mysqli, "DELETE FROM ai_models WHERE ai_model_id = $model_id");
mysqli_query($mysqli,"DELETE FROM ai_models WHERE ai_model_id = $model_id");
logAudit("AI Model", "Delete", "$session_name deleted AI Model $model_name");
flashAlert("AI Model <strong>$model_name</strong> deleted", 'error');
redirect();
logAudit("AI Model", "Delete", "$session_name deleted AI model " . escapeSql("$name on {$row['ai_provider_name']}, for {$row['ai_model_use_case']}"), 0, $model_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> deleted', 'error');
redirect('ai_models.php');
}

View File

@@ -1,65 +1,195 @@
<?php
/*
* ITFlow - GET/POST request handler for AI Providers ('ai_provider')
* ITFlow - Admin > AI Providers (admin/ai_providers.php): adding one,
* changing it, testing it and deleting it.
*
* Saving checks what was sent (aiProviderCheck(), functions/ai.php) and, when
* its kind, address or key is new or changed, asks the provider for its models
* - which says whether they work. The window asks first (check_ai_provider,
* which answers in JSON) and says what is wrong beside each field; a provider
* that doesn't answer right now can still be saved once the window has said
* so (save_anyway). Adding one can add its first model too. What changed is in
* the audit log, from what to what - the key only as changed.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['add_ai_provider'])) {
if (isset($_POST['check_ai_provider']) || isset($_POST['add_ai_provider']) || isset($_POST['edit_ai_provider'])) {
validateCSRFToken();
$provider = escapeSql($_POST['provider']);
$url = escapeSql($_POST['url']);
$model = escapeSql($_POST['model']);
$api_key = escapeSql($_POST['api_key']);
$checking = isset($_POST['check_ai_provider']);
$provider_id = intval($_POST['provider_id'] ?? 0);
$before = $provider_id ? aiProviderRow($provider_id) : null;
$answer = function ($ok, $errors, $extra = []) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'errors' => (object) $errors] + $extra);
exit;
};
mysqli_query($mysqli,"INSERT INTO ai_providers SET ai_provider_name = '$provider', ai_provider_api_url = '$url', ai_provider_api_key = '$api_key'");
if ($provider_id && !$before) {
if ($checking) {
$answer(false, ['name' => 'That provider no longer exists - close this and look again.']);
}
flashAlert('That AI provider no longer exists.', 'error');
redirect('ai_providers.php');
}
$ai_provider_id = mysqli_insert_id($mysqli);
[$after, $errors] = aiProviderCheck($_POST, $before);
logAudit("AI Provider", "Create", "$session_name created AI Provider $provider");
// Its first model, for everything - adding one while there is nothing for everything yet
$first_model = !$before && !getAiModel('General') ? trim((string) ($_POST['first_model'] ?? '')) : '';
if ($first_model !== '' && (mb_strlen($first_model) > 200 || (preg_match('/\s/', $first_model) && !in_array($after['ai_provider_kind'], ['azure', 'custom'], true)))) {
$errors['first_model'] = "That isn't a model's name - " . aiProviderKind($after['ai_provider_kind'])['example'] . ', say.';
}
flashAlert("AI Model <strong>$provider</strong> created");
// New or changed - does it answer? Once it has, not asked again for ten minutes
$test = ['ok' => true, 'models' => [], 'listed' => false, 'asked' => false];
$soft = false;
$save_anyway = !empty($_POST['save_anyway']);
if (!array_intersect_key($errors, ['kind' => 1, 'url' => 1, 'api_key' => 1]) && (aiProviderConnectionChanged($after, $before) || !empty($_POST['test'])) && !($save_anyway && !$checking)) {
$asked_key = hash('sha256', $after['ai_provider_kind'] . '|' . $after['ai_provider_api_url'] . '|' . $after['ai_provider_api_key']);
$asked_at = $_SESSION['ai_provider_asked'][$asked_key] ?? 0;
if ($checking || time() - $asked_at > 600) {
$test = aiProviderTest($after['ai_provider_kind'], $after['ai_provider_api_url'], $after['ai_provider_api_key']) + ['asked' => true];
if ($test['ok']) {
$_SESSION['ai_provider_asked'] = [$asked_key => time()];
} else {
// A key turned away is the key; anything else is the address, or it being down right now
$errors[strpos($test['error'], 'turned the key away') !== false ? 'api_key' : 'url'] = $test['error'];
$soft = true;
}
}
}
if ($checking) {
$answer(!$errors, $errors, [
'soft' => $soft && count($errors) === 1,
'models' => $test['models'],
'listed' => $test['listed'],
'asked' => $test['asked'],
'url' => $after['ai_provider_api_url'],
]);
}
// Only reached without the window's script - say the first thing wrong
if ($errors) {
flashAlert('Not saved - ' . escapeHtml(reset($errors)), 'error');
redirect();
}
$name = $after['ai_provider_name'];
$kind_name = aiProviderKind($after['ai_provider_kind'])['name'];
if (!$before) {
$provider_id = aiProviderWrite($after);
logAudit("AI Provider", "Create", "$session_name added AI provider " . escapeSql("$name ($kind_name, {$after['ai_provider_api_url']})" . ($after['ai_provider_redact'] ? '' : ' - passwords and keys sent as they are')), 0, $provider_id);
if ($first_model !== '') {
$model_id = aiModelWrite(['ai_model_ai_provider_id' => $provider_id, 'ai_model_name' => $first_model, 'ai_model_use_case' => 'General', 'ai_model_temperature' => null, 'ai_model_prompt' => '']);
logAudit("AI Model", "Create", "$session_name added AI model " . escapeSql("$first_model on $name, for General"), 0, $model_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> added, with <strong>' . escapeHtml($first_model) . '</strong> for everything - AI is ready to use');
} else {
flashAlert('<strong>' . escapeHtml($name) . '</strong> added - now add the model to use on it');
}
redirect('ai_providers.php');
}
$lines = aiProviderChangeLines($before, $after);
if (!$lines) {
flashAlert('Nothing changed on <strong>' . escapeHtml($name) . '</strong>', 'info');
redirect();
}
aiProviderWrite($after, $provider_id);
logAudit("AI Provider", "Edit", "$session_name changed AI provider " . escapeSql("$name: " . implode('; ', $lines)), 0, $provider_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> saved - ' . escapeHtml(count($lines) === 1 ? $lines[0] : count($lines) . ' changes'));
redirect();
}
if (isset($_POST['edit_ai_provider'])) {
// The models a provider has - for the model window's suggestions. JSON.
if (isset($_POST['ai_provider_models'])) {
validateCSRFToken();
$provider_id = intval($_POST['provider_id']);
$provider = escapeSql($_POST['provider']);
$url = escapeSql($_POST['url']);
$api_key = escapeSql($_POST['api_key']);
header('Content-Type: application/json; charset=utf-8');
$row = aiProviderRow($_POST['ai_provider_models']);
if (!$row) {
echo json_encode(['ok' => false, 'models' => [], 'error' => 'That provider no longer exists.', 'listed' => false, 'example' => '']);
exit;
}
// Asked once in ten minutes per provider - its models don't change from one window to the next
$cache_key = hash('sha256', $row['ai_provider_kind'] . '|' . $row['ai_provider_api_url'] . '|' . $row['ai_provider_api_key']);
$cached = $_SESSION['ai_provider_models'][$cache_key] ?? null;
if ($cached && time() - $cached['at'] < 600) {
echo json_encode($cached['answer']);
exit;
}
$test = aiProviderTest($row['ai_provider_kind'], $row['ai_provider_api_url'], $row['ai_provider_api_key']);
$answer = ['ok' => $test['ok'], 'models' => $test['models'], 'error' => $test['error'], 'listed' => $test['listed'], 'example' => aiProviderKind($row['ai_provider_kind'])['example']];
if ($test['ok']) {
$_SESSION['ai_provider_models'] = [$cache_key => ['at' => time(), 'answer' => $answer]];
}
echo json_encode($answer);
exit;
mysqli_query($mysqli,"UPDATE ai_providers SET ai_provider_name = '$provider', ai_provider_api_url = '$url', ai_provider_api_key = '$api_key' WHERE ai_provider_id = $provider_id");
}
logAudit("AI Provider", "Edit", "$session_name edited AI Provider $provider");
// Test a provider - from its menu: does it answer, with its key, and has it the models set up on it?
if (isset($_GET['test_ai_provider'])) {
flashAlert("AI Model <strong>$provider</strong> edited");
validateCSRFToken();
$row = aiProviderRow($_GET['test_ai_provider']);
if (!$row) {
flashAlert('That AI provider no longer exists.', 'error');
redirect('ai_providers.php');
}
$name = escapeHtml($row['ai_provider_name']);
$test = aiProviderTest($row['ai_provider_kind'], $row['ai_provider_api_url'], $row['ai_provider_api_key']);
if (!$test['ok']) {
logApp('AI', 'warning', "Test of AI provider {$row['ai_provider_name']} - {$test['error']}");
flashAlert("<strong>$name</strong> didn't answer as it should - " . escapeHtml($test['error']), 'error');
} elseif (!$test['listed']) {
flashAlert("<strong>$name</strong> doesn't list its models, so it can't be tested on its own - use Try it on one of its models", 'info');
} else {
$count = count($test['models']);
$ours = [];
$sql = mysqli_query($mysqli, "SELECT ai_model_name FROM ai_models WHERE ai_model_ai_provider_id = " . intval($row['ai_provider_id']) . " ORDER BY ai_model_name");
while ($model = mysqli_fetch_row($sql)) {
$ours[] = $model[0];
}
$missing = $count ? array_values(array_diff($ours, $test['models'])) : [];
flashAlert("<strong>$name</strong> answered - $count model" . ($count === 1 ? '' : 's') . ' there'
. ($missing ? '. It has no <strong>' . implode('</strong> or <strong>', array_map('escapeHtml', $missing)) . '</strong> - check the name in AI Models' : ''), $missing ? 'warning' : 'success');
}
redirect();
}
// Delete a provider - its models go with it
if (isset($_GET['delete_ai_provider'])) {
validateCSRFToken();
$provider_id = intval($_GET['delete_ai_provider']);
$provider_name = escapeSql(getFieldById('ai_providers', $provider_id, 'ai_provider_name'));
$row = aiProviderRow($_GET['delete_ai_provider']);
if (!$row) {
flashAlert('That AI provider no longer exists.', 'error');
redirect('ai_providers.php');
}
$provider_id = intval($row['ai_provider_id']);
$name = $row['ai_provider_name'];
$models = [];
$sql = mysqli_query($mysqli, "SELECT ai_model_name FROM ai_models WHERE ai_model_ai_provider_id = $provider_id ORDER BY ai_model_name");
while ($model = mysqli_fetch_row($sql)) {
$models[] = $model[0];
}
mysqli_query($mysqli,"DELETE FROM ai_providers WHERE ai_provider_id = $provider_id");
// Its models cascade with it
mysqli_query($mysqli, "DELETE FROM ai_providers WHERE ai_provider_id = $provider_id");
logAudit("AI Provider", "Delete", "$session_name deleted AI Provider $provider_name", 'error');
flashAlert("AI Provider <strong>$provider_name</strong> deleted", 'error');
redirect();
logAudit("AI Provider", "Delete", "$session_name deleted AI provider " . escapeSql($name . ($models ? ' - and with it ' . implode(', ', $models) : '')), 0, $provider_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> deleted' . ($models ? ', with ' . count($models) . ' model' . (count($models) === 1 ? '' : 's') : ''), 'error');
redirect('ai_providers.php');
}

View File

@@ -80,6 +80,10 @@ if ($config_module_enable_accounting) {
$attention[] = $problem;
}
}
// AI requests failing - and still failing
foreach (aiProblems() as $problem) {
$attention[] = $problem;
}
if (trim((string) $config_timezone) === '') {
$attention[] = ['warning', 'fa-clock', 'No timezone', 'Dates and times are in the server\'s timezone until one is picked.', 'settings_localization.php#setting=config_timezone', 'Pick one'];
}