Raiju/includes/ai/agent/agent-config.php
2026-10-25 06:05:42 +03:00

217 lines
9.8 KiB
PHP

<?php
/**
* Agent settings, rights and availability (agent phase P4/P7).
*
* Kept free of the change set code so every page that shows the assistant can ask
* "is the agent available for this user?" without loading the whole agent.
*/
require_once dirname(__DIR__) . '/ai-helper.php';
const RAIJU_AGENT_RIGHTS = ['publish', 'stockImages', 'uploadImages', 'generateImages', 'research', 'structural', 'createPages', 'createNews', 'createEvents', 'editSharedContent'];
function raijuAgentDefaultSettings(): array {
return [
'enabled' => false,
// Offer the classic assistant next to the agent in the chat (the mode switch). Off: the chat is the agent only.
'classicMode' => true,
'limits' => [
'maxTurns' => 14,
'maxToolCalls' => 40,
'maxCostCentsPerRun' => 100,
'maxRuntimeSeconds' => 900,
'maxToolFailures' => 3,
'runsPerHour' => 10,
'stepsPerMinute' => 60,
'uploadsPer10Minutes' => 10,
],
'roles' => ['editor' => [
'publish' => false, 'stockImages' => true, 'uploadImages' => true, 'generateImages' => true, 'research' => true,
'structural' => true, 'createPages' => true, 'createNews' => true, 'createEvents' => true, 'editSharedContent' => false,
]],
'mediaFolder' => 'agent',
'creditInNews' => true,
'sourcesInNews' => true,
];
}
/** Raw settings with defaults applied and every value clamped to a sane range. */
function raijuAgentNormalizeSettings(array $raw): array {
$settings = raijuAgentDefaultSettings();
$settings['enabled'] = !empty($raw['enabled']);
$ranges = ['maxTurns' => [1, 40], 'maxToolCalls' => [1, 200], 'maxCostCentsPerRun' => [1, 10000], 'maxRuntimeSeconds' => [60, 3600],
'maxToolFailures' => [1, 10], 'runsPerHour' => [1, 200], 'stepsPerMinute' => [1, 600], 'uploadsPer10Minutes' => [1, 100]];
foreach ($ranges as $key => [$min, $max]) {
if (isset($raw['limits'][$key]) && is_numeric($raw['limits'][$key])) {
$settings['limits'][$key] = max($min, min($max, (int)$raw['limits'][$key]));
}
}
foreach (RAIJU_AGENT_RIGHTS as $right) {
if (isset($raw['roles']['editor'][$right])) {
$settings['roles']['editor'][$right] = (bool)$raw['roles']['editor'][$right];
}
}
foreach (['creditInNews', 'sourcesInNews', 'classicMode'] as $flag) {
if (array_key_exists($flag, $raw)) $settings[$flag] = (bool)$raw[$flag];
}
if (isset($raw['mediaFolder']) && is_string($raw['mediaFolder']) && preg_match('/^[a-z0-9_-]{1,40}$/', $raw['mediaFolder'])) {
$settings['mediaFolder'] = $raw['mediaFolder'];
}
return $settings;
}
/** The agent settings stored in content/ai-settings.json, normalised. */
function raijuAgentSettings(): array {
$stored = raijuAiReadStoredSettings();
$raw = is_array($stored['agent'] ?? null) ? $stored['agent'] : [];
return raijuAgentNormalizeSettings($raw);
}
/** @return string[] lowercase host names (without scheme, path, port or a leading www.) */
function raijuAgentResearchDomains($value): array {
if (is_string($value)) $value = preg_split('/[\s,;]+/', $value) ?: [];
$domains = [];
foreach ((array)$value as $entry) {
if (!is_string($entry)) continue;
$entry = strtolower(trim($entry));
$entry = preg_replace('#^[a-z][a-z0-9+.-]*://#', '', $entry) ?? '';
$entry = preg_replace('#[/:?\#].*$#', '', $entry) ?? '';
$entry = preg_replace('/^www\./', '', $entry) ?? '';
if (preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $entry)) $domains[$entry] = true;
if (count($domains) >= 100) break;
}
return array_keys($domains);
}
// ---------------------------------------------------------------------------
// Saving and loading through the AI settings (dashboard)
// ---------------------------------------------------------------------------
/** An API key typed into the settings form: printable ASCII without spaces, or an error. */
function raijuAgentCleanApiKey(string $key): string {
$key = trim($key);
if ($key !== '' && !preg_match('/^[\x21-\x7E]{8,200}$/', $key)) {
throw new RuntimeException('The API key looks invalid. Paste it exactly as the provider shows it.');
}
return $key;
}
/**
* The sections "agent", "research" and "stock" for content/ai-settings.json. Keys that are not
* typed again stay as they are; "clear..." flags remove them. Sections missing from the input
* (older callers) are kept untouched.
*
* @param array $input what the settings form sent
* @param array $existing the stored settings (raw, with keys)
* @return array<string,array>
*/
function raijuAgentSettingsForSave(array $input, array $existing): array {
$out = [];
$stored = is_array($existing['agent'] ?? null) ? $existing['agent'] : [];
if (is_array($input['agent'] ?? null)) {
$folder = $input['agent']['mediaFolder'] ?? null;
if ($folder !== null && (!is_string($folder) || !preg_match('/^[a-z0-9_-]{1,40}$/', $folder))) {
throw new RuntimeException('The media folder may only contain lowercase letters, digits, - and _.');
}
$out['agent'] = raijuAgentNormalizeSettings(array_replace_recursive($stored, $input['agent']));
} elseif ($stored) {
$out['agent'] = $stored;
}
$stored = is_array($existing['research'] ?? null) ? $existing['research'] : [];
if (is_array($input['research'] ?? null)) {
$in = $input['research'];
$key = (string)($stored['brave']['apiKey'] ?? '');
if (trim((string)($in['braveApiKey'] ?? '')) !== '' && !raijuAiIsMaskedSecret(trim((string)$in['braveApiKey']))) $key = raijuAgentCleanApiKey((string)$in['braveApiKey']);
if (!empty($in['clearBraveApiKey'])) $key = '';
$out['research'] = [
'brave' => ['apiKey' => $key],
'allowDomains' => raijuAgentResearchDomains(array_key_exists('allowDomains', $in) ? $in['allowDomains'] : ($stored['allowDomains'] ?? [])),
'denyDomains' => raijuAgentResearchDomains(array_key_exists('denyDomains', $in) ? $in['denyDomains'] : ($stored['denyDomains'] ?? [])),
'respectRobots' => array_key_exists('respectRobots', $in) ? !empty($in['respectRobots']) : (!array_key_exists('respectRobots', $stored) || !empty($stored['respectRobots'])),
];
} elseif ($stored) {
$out['research'] = $stored;
}
$stored = is_array($existing['stock'] ?? null) ? $existing['stock'] : [];
if (is_array($input['stock'] ?? null)) {
$in = $input['stock'];
$key = (string)($stored['pexels']['apiKey'] ?? '');
if (trim((string)($in['pexelsApiKey'] ?? '')) !== '' && !raijuAiIsMaskedSecret(trim((string)$in['pexelsApiKey']))) $key = raijuAgentCleanApiKey((string)$in['pexelsApiKey']);
if (!empty($in['clearPexelsApiKey'])) $key = '';
$out['stock'] = ['pexels' => ['apiKey' => $key]];
} elseif ($stored) {
$out['stock'] = $stored;
}
return $out;
}
/** The same sections for the browser: no keys, only whether they are set. */
function raijuAgentSettingsForPublic(array $settings): array {
$research = is_array($settings['research'] ?? null) ? $settings['research'] : [];
$stock = is_array($settings['stock'] ?? null) ? $settings['stock'] : [];
return [
'agent' => raijuAgentNormalizeSettings(is_array($settings['agent'] ?? null) ? $settings['agent'] : []),
'research' => [
'hasBraveApiKey' => trim((string)($research['brave']['apiKey'] ?? '')) !== '',
'allowDomains' => raijuAgentResearchDomains($research['allowDomains'] ?? []),
'denyDomains' => raijuAgentResearchDomains($research['denyDomains'] ?? []),
'respectRobots' => !array_key_exists('respectRobots', $research) || !empty($research['respectRobots']),
],
'stock' => ['hasPexelsApiKey' => trim((string)($stock['pexels']['apiKey'] ?? '')) !== ''],
];
}
/** What the settings form shows next to the agent switch (admins only). */
function raijuAgentStatusForAdmin(): array {
$reason = '';
$ai = raijuAiLoadSettings(false);
return [
'available' => raijuAgentAvailable($reason),
'reason' => $reason,
'toolCalling' => raijuAiSupportsToolCalling($ai),
'gd' => extension_loaded('gd'),
'webp' => function_exists('imagewebp'),
'limits' => ['maxInputTokens' => (int)$ai['limits']['maxInputTokens'], 'dailyRequests' => (int)$ai['limits']['dailyRequests'], 'dailyTextRequests' => (int)$ai['limits']['dailyTextRequests']],
];
}
function raijuAgentRole(): string {
$role = (string)($_SESSION['admin_role'] ?? '');
return in_array($role, ['admin', 'editor'], true) ? $role : '';
}
/** Whether the signed-in user holds a right ("edit" is implicit for editors and admins). */
function raijuAgentCan(string $right): bool {
$role = raijuAgentRole();
if ($role === 'admin') return true;
if ($role !== 'editor') return false;
if ($right === 'edit' || $right === 'chat') return true;
return !empty(raijuAgentSettings()['roles']['editor'][$right]);
}
/**
* Whether the agent can run at all: switched on, AI enabled with a key, and a provider
* that supports tool calling. Otherwise the classic Copilot stays the only assistant.
*/
function raijuAgentAvailable(?string &$reason = null): bool {
if (!raijuAgentSettings()['enabled']) {
$reason = 'The AI agent is switched off.';
return false;
}
$ai = raijuAiLoadSettings(false);
try {
raijuAiEnsureEnabled($ai);
} catch (RuntimeException $e) {
$reason = $e->getMessage();
return false;
}
if (!raijuAiSupportsToolCalling($ai)) {
$reason = 'The configured AI provider does not support tool calling.';
return false;
}
return true;
}