308 lines
14 KiB
PHP
308 lines
14 KiB
PHP
<?php
|
|
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
|
|
|
|
// AI agent: runs (start/step/status/reply/cancel) and change sets (review/apply/undo/publish).
|
|
// The dispatcher has already checked the session and the CSRF token of every POST.
|
|
require_once RAIJU_ADMIN_DIR . '/../includes/ai/agent/run.php';
|
|
require_once RAIJU_ADMIN_DIR . '/../includes/ai/agent/rate-limit.php';
|
|
|
|
if (!function_exists('raijuAgentApiGuard')) {
|
|
/** Module, right and (for actions that need the model) availability; returns the acting user. */
|
|
function raijuAgentApiGuard(bool $needsModel): array {
|
|
if (!dashboardAiModuleEnabled()) {
|
|
jsonResponse(false, null, 'AI module is disabled');
|
|
}
|
|
if (!raijuAgentCan('chat')) {
|
|
http_response_code(403);
|
|
jsonResponse(false, null, 'Forbidden');
|
|
}
|
|
if ($needsModel) {
|
|
$reason = '';
|
|
if (!raijuAgentAvailable($reason)) {
|
|
jsonResponse(false, ['available' => false], $reason);
|
|
}
|
|
}
|
|
$actor = raijuAgentActor();
|
|
if ($actor['userKey'] === '') {
|
|
http_response_code(403);
|
|
jsonResponse(false, null, 'Forbidden');
|
|
}
|
|
return $actor;
|
|
}
|
|
|
|
function raijuAgentApiRateLimit(array $actor, string $bucket): void {
|
|
$limits = raijuAgentSettings()['limits'];
|
|
$config = ['runs' => ['limit' => $limits['runsPerHour'], 'window' => 3600], 'steps' => ['limit' => $limits['stepsPerMinute'], 'window' => 60]][$bucket];
|
|
$result = raijuAgentRateLimit($actor['userKey'], $bucket, $config);
|
|
if (!$result['allowed']) {
|
|
http_response_code(429);
|
|
header('Retry-After: ' . $result['retryAfter']);
|
|
jsonResponse(false, ['retryAfter' => $result['retryAfter']], 'Too many agent requests. Please wait a moment and try again.');
|
|
}
|
|
}
|
|
|
|
function raijuAgentApiRunId(): string {
|
|
$id = $_POST['runId'] ?? $_GET['runId'] ?? '';
|
|
if (!is_string($id) || !raijuAgentValidRunId($id)) {
|
|
jsonResponse(false, null, 'Invalid run');
|
|
}
|
|
return $id;
|
|
}
|
|
|
|
function raijuAgentApiChangesetId(): string {
|
|
$id = $_POST['id'] ?? $_GET['id'] ?? '';
|
|
if (!is_string($id) || !raijuAgentValidChangesetId($id)) {
|
|
jsonResponse(false, null, 'Invalid change set');
|
|
}
|
|
return $id;
|
|
}
|
|
}
|
|
|
|
switch ($action) {
|
|
case 'ai-agent-start':
|
|
$actor = raijuAgentApiGuard(true);
|
|
try {
|
|
raijuCopilotAssertBurstLimit('agent', 120, 60);
|
|
} catch (Throwable $e) {
|
|
jsonResponse(false, null, $e->getMessage());
|
|
}
|
|
raijuAgentApiRateLimit($actor, 'runs');
|
|
try {
|
|
$attached = (string)($_POST['changesetId'] ?? '');
|
|
if ($attached !== '' && !raijuAgentValidChangesetId($attached)) {
|
|
jsonResponse(false, null, 'Invalid change set');
|
|
}
|
|
$run = raijuAgentRunStart($actor, (string)($_POST['goal'] ?? ''), $attached);
|
|
} catch (InvalidArgumentException $e) {
|
|
jsonResponse(false, null, $e->getMessage());
|
|
}
|
|
// Housekeeping is cheap and rare: once an hour is enough.
|
|
$marker = raijuAgentDir() . '/cleanup.stamp';
|
|
if (!is_file($marker) || filemtime($marker) < time() - 3600) {
|
|
@touch($marker);
|
|
raijuAgentCleanup();
|
|
}
|
|
jsonResponse(true, raijuAgentRunPublicState($run), 'Run started');
|
|
break;
|
|
|
|
case 'ai-agent-step':
|
|
$actor = raijuAgentApiGuard(true);
|
|
$runId = raijuAgentApiRunId();
|
|
try {
|
|
raijuCopilotAssertBurstLimit('agent', 120, 60);
|
|
} catch (Throwable $e) {
|
|
jsonResponse(false, null, $e->getMessage());
|
|
}
|
|
raijuAgentApiRateLimit($actor, 'steps');
|
|
// A step can take a while; do not hold the session lock (other tabs, the editor) meanwhile.
|
|
session_write_close();
|
|
$result = raijuAgentRunStep($runId, $actor);
|
|
if (($result['status'] ?? '') === 'missing') {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'Unknown run');
|
|
}
|
|
if (!empty($result['busy'])) {
|
|
jsonResponse(true, ['runId' => $runId, 'status' => 'busy'], 'Another step of this run is in progress');
|
|
}
|
|
jsonResponse(true, $result['state'], 'OK');
|
|
break;
|
|
|
|
case 'ai-agent-upload':
|
|
$actor = raijuAgentApiGuard(true);
|
|
if (!raijuAgentCan('uploadImages')) {
|
|
http_response_code(403);
|
|
jsonResponse(false, null, 'Forbidden');
|
|
}
|
|
$limits = raijuAgentSettings()['limits'];
|
|
$rate = raijuAgentRateLimit($actor['userKey'], 'uploads', ['limit' => $limits['uploadsPer10Minutes'], 'window' => 600]);
|
|
if (!$rate['allowed']) {
|
|
http_response_code(429);
|
|
header('Retry-After: ' . $rate['retryAfter']);
|
|
jsonResponse(false, ['retryAfter' => $rate['retryAfter']], 'Too many uploads. Please wait a moment and try again.');
|
|
}
|
|
$file = $_FILES['image'] ?? $_FILES['file'] ?? null;
|
|
if (!is_array($file) || !is_string($file['tmp_name'] ?? null) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
|
|
jsonResponse(false, null, 'Upload error');
|
|
}
|
|
$imageConfig = getMediaConfig('image');
|
|
$extension = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
|
|
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
|
jsonResponse(false, null, 'Only JPEG, PNG and WebP images are accepted.');
|
|
}
|
|
if ((int)$file['size'] > (int)$imageConfig['maxSize']) {
|
|
jsonResponse(false, null, 'File too large');
|
|
}
|
|
$attached = (string)($_POST['changesetId'] ?? '');
|
|
if ($attached !== '') {
|
|
$target = raijuAgentValidChangesetId($attached) ? raijuAgentChangesetLoad($attached) : null;
|
|
if ($target === null || (string)$target['owner'] !== $actor['userKey'] || $target['status'] !== 'staging') {
|
|
jsonResponse(false, null, 'Unknown change set');
|
|
}
|
|
} else {
|
|
$attached = raijuAgentChangesetCreate(['owner' => $actor['userKey']])['id'];
|
|
}
|
|
try {
|
|
$image = raijuAgentIngestImage($file['tmp_name'], ['maxBytes' => (int)$imageConfig['maxSize']]);
|
|
$record = raijuAgentMediaRegister($attached, $image, ['source' => 'upload', 'name' => (string)$file['name'], 'ai' => $image['ai']]);
|
|
} catch (RuntimeException $e) {
|
|
jsonResponse(false, null, $e->getMessage());
|
|
}
|
|
raijuAgentAudit('agent-upload', true, ['cs' => $attached, 'ref' => $record['ref'], 'bytes' => $record['bytes']]);
|
|
jsonResponse(true, ['changesetId' => $attached, 'media' => raijuAgentMediaSummary($record['ref'], $record),
|
|
'thumbnail' => 'admin/api.php?action=ai-agent-media&id=' . $attached . '&ref=' . $record['ref']], 'Uploaded');
|
|
break;
|
|
|
|
case 'ai-agent-clear-cache':
|
|
// The cached scan of the page templates; rebuilt on demand. Settings area, administrators only.
|
|
if (!dashboardAiModuleEnabled()) jsonResponse(false, null, 'AI module is disabled');
|
|
if (!isAdmin()) {
|
|
http_response_code(403);
|
|
jsonResponse(false, null, 'Forbidden');
|
|
}
|
|
$removed = raijuAgentClearCapabilityCache();
|
|
raijuAgentAudit('agent-cache-clear', true, ['files' => $removed]);
|
|
jsonResponse(true, ['removed' => $removed], 'Cache cleared');
|
|
break;
|
|
|
|
case 'ai-agent-media':
|
|
// Staged images (uploads, stock photos, generated images) for the review panel; owner and admins only.
|
|
$actor = raijuAgentApiGuard(false);
|
|
$cs = raijuAgentChangesetLoad(raijuAgentApiChangesetId());
|
|
$ref = (string)($_GET['ref'] ?? '');
|
|
$media = ($cs !== null && preg_match('/^m_[a-f0-9]{8}$/', $ref)) ? ($cs['media'][$ref] ?? null) : null;
|
|
if ($cs === null || $media === null || !raijuAgentCanSeeChangeset($cs, $actor)) {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'Unknown image');
|
|
}
|
|
$stagingDir = realpath(raijuAgentMediaStagingDir($cs['id']));
|
|
$file = realpath(raijuAgentAbsolute((string)$media['stagingFile']));
|
|
if ($stagingDir === false || $file === false || !str_starts_with($file, $stagingDir . DIRECTORY_SEPARATOR) || !is_file($file)) {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'The image is no longer staged');
|
|
}
|
|
header_remove('Content-Type');
|
|
header('Content-Type: ' . ($media['mime'] ?? 'image/webp'));
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('Cache-Control: private, max-age=300');
|
|
header('Content-Length: ' . filesize($file));
|
|
readfile($file);
|
|
exit;
|
|
|
|
case 'ai-agent-status':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$state = raijuAgentRunStatus(raijuAgentApiRunId(), $actor, (int)($_GET['after'] ?? 0));
|
|
if ($state === null) {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'Unknown run');
|
|
}
|
|
jsonResponse(true, $state, 'OK');
|
|
break;
|
|
|
|
case 'ai-agent-reply':
|
|
$actor = raijuAgentApiGuard(true);
|
|
$result = raijuAgentRunReply(raijuAgentApiRunId(), $actor, (string)($_POST['message'] ?? ''));
|
|
if (($result['status'] ?? '') === 'missing') {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'Unknown run');
|
|
}
|
|
if (!empty($result['busy'])) {
|
|
jsonResponse(true, ['status' => 'busy'], 'The agent is working; try again in a moment');
|
|
}
|
|
if (isset($result['error'])) {
|
|
jsonResponse(false, null, $result['error']);
|
|
}
|
|
jsonResponse(true, $result['state'], 'OK');
|
|
break;
|
|
|
|
case 'ai-agent-cancel':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$result = raijuAgentRunCancel(raijuAgentApiRunId(), $actor);
|
|
if (($result['status'] ?? '') === 'missing') {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'Unknown run');
|
|
}
|
|
if (isset($result['error'])) {
|
|
jsonResponse(false, null, $result['error']);
|
|
}
|
|
jsonResponse(true, $result['state'] ?? ['status' => $result['status'] ?? 'cancelling'], 'OK');
|
|
break;
|
|
|
|
case 'ai-agent-changeset':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$cs = raijuAgentChangesetLoad(raijuAgentApiChangesetId());
|
|
if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) {
|
|
http_response_code(404);
|
|
jsonResponse(false, null, 'Unknown change set');
|
|
}
|
|
// After the apply the owner needs the items to publish and the undo token, also after a reload.
|
|
$applied = $cs['status'] === 'applied' && !empty($cs['applied'])
|
|
? ['items' => array_values((array)$cs['applied']['items']), 'undoToken' => raijuAgentUndoToken($cs['id'])] : null;
|
|
jsonResponse(true, raijuAgentChangesetPreview($cs) + ['canPublish' => $actor['canPublish'], 'applied' => $applied], 'OK');
|
|
break;
|
|
|
|
case 'ai-agent-revalidate':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$id = raijuAgentApiChangesetId();
|
|
$result = raijuAgentChangesetRevalidate($id, $actor);
|
|
if (empty($result['ok'])) {
|
|
// The status of a change set is only for its owner (and admins).
|
|
$current = raijuAgentChangesetLoad($id);
|
|
$visible = $current !== null && raijuAgentCanSeeChangeset($current, $actor);
|
|
jsonResponse(false, ['status' => $visible ? $current['status'] : 'missing'], (string)($result['error'] ?? 'Revalidation failed'));
|
|
}
|
|
$report = raijuAgentChangesetFinalize($id);
|
|
jsonResponse(true, ['status' => $report['status'], 'renderCheck' => $report['renderCheck']['status'] ?? 'skipped', 'previewHash' => $report['previewHash']], 'OK');
|
|
break;
|
|
|
|
case 'ai-agent-apply':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$id = raijuAgentApiChangesetId();
|
|
if (($_POST['confirmed'] ?? '') !== '1') {
|
|
jsonResponse(false, null, 'Confirmation required');
|
|
}
|
|
$result = raijuAgentChangesetApply($id, (string)($_POST['previewHash'] ?? ''), $actor, ['confirmRisk' => ($_POST['confirmRisk'] ?? '') === '1']);
|
|
if (empty($result['ok'])) {
|
|
if (($result['status'] ?? '') === 'conflict') {
|
|
http_response_code(409);
|
|
}
|
|
jsonResponse(false, ['status' => $result['status'] ?? 'failed', 'needsRiskConfirm' => !empty($result['needsRiskConfirm'])], (string)$result['error']);
|
|
}
|
|
jsonResponse(true, ['status' => 'applied', 'undoToken' => $result['undoToken'], 'items' => $result['items'], 'files' => $result['files']], 'Applied');
|
|
break;
|
|
|
|
case 'ai-agent-discard':
|
|
$actor = raijuAgentApiGuard(false);
|
|
if (!raijuAgentChangesetDiscard(raijuAgentApiChangesetId(), $actor)) {
|
|
jsonResponse(false, null, 'This change set cannot be discarded');
|
|
}
|
|
jsonResponse(true, ['status' => 'discarded'], 'Discarded');
|
|
break;
|
|
|
|
case 'ai-agent-undo':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$result = raijuAgentChangesetUndo(raijuAgentApiChangesetId(), (string)($_POST['token'] ?? ''), $actor);
|
|
if (empty($result['ok'])) {
|
|
jsonResponse(false, ['status' => $result['status'] ?? 'failed', 'needsRiskConfirm' => !empty($result['needsRiskConfirm'])], (string)$result['error']);
|
|
}
|
|
jsonResponse(true, ['status' => 'undone'], 'Undone');
|
|
break;
|
|
|
|
case 'ai-agent-publish':
|
|
$actor = raijuAgentApiGuard(false);
|
|
$id = raijuAgentApiChangesetId();
|
|
$item = json_decode((string)($_POST['item'] ?? ''), true);
|
|
if (!is_array($item) || !is_string($item['kind'] ?? null) || !is_string($item['target'] ?? null)) {
|
|
jsonResponse(false, null, 'Invalid item');
|
|
}
|
|
$item = array_intersect_key($item, array_flip(['kind', 'target', 'listKey', 'index', 'sectionId']));
|
|
$result = raijuAgentChangesetPublish($id, $item, $actor);
|
|
if (empty($result['ok'])) {
|
|
if (str_contains((string)$result['error'], 'not allowed to publish')) {
|
|
http_response_code(403);
|
|
}
|
|
jsonResponse(false, null, (string)$result['error']);
|
|
}
|
|
jsonResponse(true, ['status' => 'published'], 'Published');
|
|
break;
|
|
}
|