Raiju/admin/api/agent.php
2026-10-25 06:05:42 +03:00

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