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 */ 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; }