mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
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:
@@ -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) . '&sort=' . $column . '&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 ?>&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"; ?>
|
||||
|
||||
@@ -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) . '&sort=' . $column . '&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 ?>&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
276
admin/ai_usage.php
Normal 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) . '&sort=' . $column . '&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";
|
||||
67
admin/database_updates/3.3.8.php
Normal file
67
admin/database_updates/3.3.8.php
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
?>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user