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

1774 lines
83 KiB
PHP

<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
require_once RAIJU_ADMIN_DIR . '/../includes/ai/agent/agent-config.php';
require_once RAIJU_ADMIN_DIR . '/../includes/ai/dashboard-actions.php';
switch ($action) {
case 'ai-resolve-request':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
try {
raijuAiResolveReservation((string)($_POST['request_id'] ?? ''), (string)($_POST['resolution'] ?? ''));
jsonResponse(true, raijuAiUsageSummary());
} catch (Throwable $error) { jsonResponse(false, null, $error->getMessage()); }
break;
case 'load-ai-settings':
if (!dashboardAiModuleEnabled()) {
jsonResponse(true, [
'settings' => null,
'usage' => null
]);
}
// Keys that an older version stored in ai-settings.json move to ai-secrets.json (nothing happens when there are none).
if (isAdmin()) raijuAiMigrateSecrets();
$loaded = raijuAiLoadSettings(true);
if (isAdmin()) $loaded['agentStatus'] = raijuAgentStatusForAdmin();
jsonResponse(true, [
'settings' => $loaded,
'usage' => raijuAiUsageSummary()
]);
break;
case 'ai-image-history':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
jsonResponse(true, raijuAiLoadImageHistory((int)($_GET['offset'] ?? 0), (int)($_GET['limit'] ?? 12)));
break;
case 'ai-image-jobs':
if (!raijuApiCanUseImageJobs()) {
jsonResponse(false, null, 'Forbidden');
}
$openOnly = !empty($_GET['open_only']) || !empty($_POST['open_only']);
$userFilter = isAdmin() ? null : raijuApiCurrentAiUser();
jsonResponse(true, ['jobs' => raijuAiListImageJobs($openOnly, (int)($_GET['limit'] ?? $_POST['limit'] ?? 20), $userFilter)]);
break;
case 'ai-image-job-run':
if (!raijuApiCanUseImageJobs()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$jobId = trim((string)($_POST['job_id'] ?? $_GET['job_id'] ?? ''));
if ($jobId === '') {
jsonResponse(false, null, 'Image job ID is required');
}
try {
$pendingJob = raijuAiRefreshImageJobState(raijuAiLoadImageJob($jobId));
raijuApiAssertImageJobAccess($pendingJob);
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
if ((string)($pendingJob['status'] ?? '') === 'queued' && raijuApiTrySpawnLocalImageJobWorker($jobId)) {
jsonResponse(true, [
'job' => raijuAiPublicImageJob($pendingJob),
'worker' => 'cli'
]);
}
// Detach for queued jobs so generation survives closed tabs and
// page navigation; clients pick up the result via job polling.
$detached = (string)($pendingJob['status'] ?? '') === 'queued' && raijuApiDetachResponse([
'success' => true,
'data' => ['job' => raijuAiPublicImageJob($pendingJob)],
'message' => ''
]);
$job = raijuApiRunImageJob($jobId);
if ($detached) {
exit;
}
jsonResponse(true, ['job' => $job]);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'clear-ai-image-history':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
raijuAiClearImageHistory();
jsonResponse(true, raijuAiLoadImageHistory(0, 12), 'AI image history cleared');
break;
case 'save-ai-settings':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$settings = json_decode($_POST['settings'] ?? '{}', true);
if (!is_array($settings)) {
jsonResponse(false, null, 'Invalid AI settings JSON');
}
try {
$saved = raijuAiSaveSettings($settings);
$saved['agentStatus'] = raijuAgentStatusForAdmin();
jsonResponse(true, [
'settings' => $saved,
'usage' => raijuAiUsageSummary()
], 'AI settings saved');
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-clear-secret':
// Removes one stored key at once, independent of the rest of the form: provider:<provider>, brave or pexels.
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
$removed = raijuAiClearSecret((string)($_POST['target'] ?? ''));
raijuAiAudit('ai-key-removed', true, ['target' => (string)($_POST['target'] ?? '')]);
$saved = raijuAiLoadSettings(true);
$saved['agentStatus'] = raijuAgentStatusForAdmin();
jsonResponse(true, ['removed' => $removed, 'settings' => $saved, 'usage' => raijuAiUsageSummary()], 'API key removed');
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-test':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
$result = raijuAiGenerateText('Reply with exactly: raiju AI connection OK', [
'feature' => '',
'maxOutputTokens' => 256,
'temperature' => 0
]);
jsonResponse(true, $result, 'AI connection works');
} catch (Throwable $e) {
raijuAiAudit('test', false, ['message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-chat':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$messages = json_decode($_POST['messages'] ?? '[]', true);
if (!is_array($messages)) {
jsonResponse(false, null, 'Invalid messages JSON');
}
try {
$settings = raijuAiLoadSettings(false);
$system = $settings['systemPrompts']['assistant'] ?? raijuAiDefaults()['systemPrompts']['assistant'];
array_unshift($messages, ['role' => 'system', 'content' => $system]);
$result = raijuAiChat($messages, [
'feature' => 'backendAssistant',
'maxOutputTokens' => $_POST['maxOutputTokens'] ?? null
]);
jsonResponse(true, $result);
} catch (Throwable $e) {
raijuAiAudit('chat', false, ['message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-context':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$contentPage = trim((string)($_POST['contentPage'] ?? $_GET['contentPage'] ?? ''));
$settings = raijuAiLoadSettings(true);
$settings['assistantUiLanguage'] = dashboardCopilotUiLanguage();
$context = raijuCopilotBuildContext($contentPage, $settings);
$context['limitWarnings'] = raijuAiUsageSummary()['warnings'];
// The dashboard assistant can offer actions (open a section, change the look) when the provider takes tool calls.
$context['dashboardActions'] = ($_POST['mode'] ?? '') === 'dashboard' && raijuDashboardActionsAvailable();
jsonResponse(true, $context);
break;
case 'ai-copilot-history-list':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('chat')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('history-list', 30, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$userKey = dashboardCopilotCurrentUserKey();
$items = [];
foreach (glob(dashboardCopilotHistoryDir() . '*.json') ?: [] as $file) {
$chat = json_decode((string)file_get_contents($file), true);
if (!is_array($chat) || (string)($chat['user'] ?? '') !== $userKey) {
continue;
}
$items[] = dashboardCopilotHistorySummary($chat);
}
usort($items, fn($a, $b) => strcmp((string)($b['updatedAt'] ?? ''), (string)($a['updatedAt'] ?? '')));
jsonResponse(true, ['items' => array_slice($items, 0, 80)]);
break;
case 'ai-copilot-history-load':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('chat')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('history-load', 30, 60);
$chat = dashboardCopilotLoadOwnedHistory(trim((string)($_POST['id'] ?? '')));
jsonResponse(true, [
'chat' => [
'id' => (string)($chat['id'] ?? ''),
'title' => (string)($chat['title'] ?? ''),
'contentPage' => (string)($chat['contentPage'] ?? ''),
'pageTitle' => (string)($chat['pageTitle'] ?? ''),
'url' => (string)($chat['url'] ?? ''),
'messages' => dashboardCopilotCleanHistoryMessages($chat['messages'] ?? []),
'lastInstruction' => (string)($chat['lastInstruction'] ?? ''),
'lastImageResult' => dashboardCopilotCleanImageResult($chat['lastImageResult'] ?? null),
'createdAt' => (string)($chat['createdAt'] ?? ''),
'updatedAt' => (string)($chat['updatedAt'] ?? '')
]
]);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-history-save':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('chat')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('history-save', 60, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$messages = dashboardCopilotCleanHistoryMessages(json_decode((string)($_POST['messages'] ?? '[]'), true));
if (!$messages) {
jsonResponse(false, null, 'No chat messages to archive');
}
try {
$requestedId = trim((string)($_POST['id'] ?? ''));
$id = dashboardCopilotHistoryId($requestedId);
$path = dashboardCopilotHistoryPath($id);
$existing = [];
if ($requestedId !== '' && is_file($path)) {
$existing = dashboardCopilotLoadOwnedHistory($id);
}
$now = date('c');
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
foreach ($messages as $message) {
if (($message['role'] ?? '') === 'user') {
$title = substr((string)$message['content'], 0, 90);
break;
}
}
}
$chat = [
'id' => $id,
'user' => dashboardCopilotCurrentUserKey(),
'title' => $title,
'contentPage' => substr(trim((string)($_POST['contentPage'] ?? '')), 0, 120),
'pageTitle' => substr(trim((string)($_POST['pageTitle'] ?? '')), 0, 160),
'url' => substr(trim((string)($_POST['url'] ?? '')), 0, 500),
'messages' => $messages,
'lastInstruction' => substr((string)($_POST['lastInstruction'] ?? ''), 0, 3000),
'lastImageResult' => dashboardCopilotCleanImageResult(json_decode((string)($_POST['lastImageResult'] ?? 'null'), true)),
'createdAt' => (string)($existing['createdAt'] ?? $now),
'updatedAt' => $now
];
if (file_put_contents($path, json_encode($chat, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
throw new RuntimeException('Could not save chat history');
}
jsonResponse(true, ['chat' => dashboardCopilotHistorySummary($chat)]);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-history-delete':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('chat')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('history-delete', 20, 60);
$id = trim((string)($_POST['id'] ?? ''));
dashboardCopilotLoadOwnedHistory($id);
$path = dashboardCopilotHistoryPath($id);
if (is_file($path) && !unlink($path)) {
throw new RuntimeException('Could not delete chat history');
}
jsonResponse(true, ['id' => $id], 'Chat history deleted');
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-chat':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('chat')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('chat', 20, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$messages = json_decode((string)($_POST['messages'] ?? '[]'), true);
if (!is_array($messages)) {
jsonResponse(false, null, 'Invalid messages JSON');
}
$cleanMessages = [];
foreach (array_slice($messages, -8) as $message) {
if (!is_array($message)) {
continue;
}
$role = (string)($message['role'] ?? '');
if (!in_array($role, ['user', 'assistant'], true)) {
continue;
}
$content = trim((string)($message['content'] ?? ''));
if ($content === '') {
continue;
}
$cleanMessages[] = [
'role' => $role,
'content' => substr($content, 0, 2200)
];
}
if (!$cleanMessages) {
jsonResponse(false, null, 'Message is required');
}
try {
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$lastUser = raijuDashboardLastUserMessage($cleanMessages);
if ($refusal = raijuAgentSafetyScreenRequest($lastUser)) {
// First line of the safety check: no model call for a request that asks for something harmful.
raijuAiAudit('copilot-chat', false, ['safety' => $refusal['category']]);
jsonResponse(true, ['reply' => raijuAgentSafetyRefusalText($refusal['category']), 'actions' => [], 'usage' => null, 'limits' => null]);
}
$settings = raijuAiLoadSettings(true);
$settings['assistantUiLanguage'] = dashboardCopilotUiLanguage();
$context = raijuCopilotBuildContext($contentPage, $settings);
$system = raijuCopilotSystemPrompt($context);
$isDashboard = ($_POST['mode'] ?? '') === 'dashboard';
$withActions = $isDashboard && raijuDashboardActionsAvailable();
if ($isDashboard) $system .= raijuDashboardKnowledgePrompt(isAdmin(), $withActions);
if ($withActions) $system .= raijuDashboardActionPrompt(isAdmin());
array_unshift($cleanMessages, ['role' => 'system', 'content' => $system]);
$actions = [];
if ($withActions) {
// One turn with the dashboard tools: an answer, or cards the person confirms. Nothing is changed here.
$turn = raijuAiChatWithTools($cleanMessages, raijuDashboardActionTools(isAdmin(), true), [
'feature' => 'backendAssistant',
'maxOutputTokens' => 4000,
'temperature' => 0.25
]);
$actions = $turn['kind'] === 'tool_calls'
? raijuDashboardActionsFromCalls($turn['toolCalls'], isAdmin(), true, raijuDashboardCurrentSettings())
: [];
$finished = raijuDashboardFinishTurn((string)$turn['text'], $actions, $turn['kind'] === 'tool_calls', $lastUser, isAdmin());
$actions = $finished['actions'];
$result = ['text' => $finished['text'], 'usage' => $turn['usage'], 'limits' => ['warnings' => raijuAiUsageSummary()['warnings']]];
} else {
$result = raijuAiChat($cleanMessages, [
'feature' => 'backendAssistant',
'maxOutputTokens' => $_POST['maxOutputTokens'] ?? 900,
'temperature' => 0.25
]);
if ($isDashboard) { // no tool calling here: the places are found from the person's words
$finished = raijuDashboardFinishTurn((string)($result['text'] ?? ''), [], false, $lastUser, isAdmin());
$actions = $finished['actions'];
$result['text'] = $finished['text'];
}
}
raijuAiAudit('copilot-chat', true, [
'contentPage' => $contentPage,
'messages' => count($cleanMessages) - 1,
'actions' => array_column($actions, 'type')
]);
jsonResponse(true, [
'reply' => (string)($result['text'] ?? ''),
'actions' => $actions,
'usage' => $result['usage'] ?? null,
'limits' => $result['limits'] ?? null,
'context' => $context
]);
} catch (Throwable $e) {
raijuAiAudit('copilot-chat', false, ['message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-chat-stream':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('chat')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('chat', 20, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$messages = json_decode((string)($_POST['messages'] ?? '[]'), true);
if (!is_array($messages)) {
jsonResponse(false, null, 'Invalid messages JSON');
}
$cleanMessages = [];
foreach (array_slice($messages, -8) as $message) {
if (!is_array($message)) {
continue;
}
$role = (string)($message['role'] ?? '');
if (!in_array($role, ['user', 'assistant'], true)) {
continue;
}
$content = trim((string)($message['content'] ?? ''));
if ($content === '') {
continue;
}
$cleanMessages[] = [
'role' => $role,
'content' => substr($content, 0, 2200)
];
}
if (!$cleanMessages) {
jsonResponse(false, null, 'Message is required');
}
try {
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$lastUser = raijuDashboardLastUserMessage($cleanMessages);
$refusal = raijuAgentSafetyScreenRequest($lastUser);
$settings = raijuAiLoadSettings(true);
$settings['assistantUiLanguage'] = dashboardCopilotUiLanguage();
$context = raijuCopilotBuildContext($contentPage, $settings);
$system = raijuCopilotSystemPrompt($context);
$isDashboard = ($_POST['mode'] ?? '') === 'dashboard';
if ($isDashboard) $system .= raijuDashboardKnowledgePrompt(isAdmin(), false);
array_unshift($cleanMessages, ['role' => 'system', 'content' => $system]);
header_remove('Content-Type');
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
while (ob_get_level() > 0) {
ob_end_flush();
}
$emitEvent = static function (array $payload): void {
echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . "\n\n";
flush();
};
if ($refusal) { // first line of the safety check: no model call
raijuAiAudit('copilot-chat', false, ['safety' => $refusal['category'], 'streamed' => true]);
$emitEvent(['done' => true, 'reply' => raijuAgentSafetyRefusalText($refusal['category']), 'usage' => null, 'limits' => null, 'context' => $context]);
exit;
}
$result = raijuAiChatStream($cleanMessages, [
'feature' => 'backendAssistant',
'maxOutputTokens' => $_POST['maxOutputTokens'] ?? 900,
'temperature' => 0.25
], static function (string $delta) use ($emitEvent): void {
$emitEvent(['delta' => $delta]);
});
raijuAiAudit('copilot-chat', true, [
'contentPage' => $contentPage,
'streamed' => true,
'messages' => count($cleanMessages) - 1
]);
$finished = $isDashboard
? raijuDashboardFinishTurn((string)($result['text'] ?? ''), [], false, $lastUser, isAdmin())
: ['text' => (string)($result['text'] ?? ''), 'actions' => []];
$emitEvent([
'done' => true,
'reply' => $finished['text'],
'actions' => $finished['actions'],
'usage' => $result['usage'] ?? null,
'limits' => $result['limits'] ?? null,
'context' => $context
]);
exit;
} catch (Throwable $e) {
raijuAiAudit('copilot-chat', false, ['message' => $e->getMessage()]);
if (headers_sent()) {
echo 'data: ' . json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . "\n\n";
flush();
exit;
}
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-suggest':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('suggestField')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('suggest', 12, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$instruction = trim((string)($_POST['instruction'] ?? ''));
$fieldRef = trim((string)($_POST['fieldRef'] ?? ''));
if ($instruction === '') {
jsonResponse(false, null, 'Instruction is required');
}
try {
$settings = raijuAiLoadSettings(true);
$context = raijuCopilotBuildContext($contentPage, $settings);
if (empty($context['page']['exists'])) {
jsonResponse(false, null, 'Content page not found');
}
$fields = raijuCopilotAllowedSuggestionFields($context, $fieldRef);
if (!$fields) {
jsonResponse(false, null, 'No editable text fields are available for AI suggestions on this page.');
}
$pageData = raijuCopilotLoadPageData($contentPage);
$prompt = raijuCopilotBuildSuggestionPrompt($context, $fields, substr($instruction, 0, 2200));
$result = raijuAiGenerateText($prompt, [
'feature' => 'backendAssistant',
'maxOutputTokens' => 1200,
'temperature' => 0.2,
'system' => 'You produce safe draft content changes for raiju CMS. Return strict JSON only.'
]);
$raw = raijuCopilotExtractJsonObject((string)($result['text'] ?? ''));
$proposals = raijuCopilotValidateProposals($raw, $context, $pageData, 3, $safetyReport);
if (!$proposals && ($safetyReport['blocked'] ?? 0) > 0) {
throw new RuntimeException(t('copilot.safety_blocked'));
}
raijuAiAudit('copilot-suggest', true, [
'contentPage' => $contentPage,
'fieldRef' => $fieldRef,
'proposalCount' => count($proposals),
'proposals' => dashboardCopilotProposalAuditSummary($proposals)
]);
jsonResponse(true, [
'proposals' => $proposals,
'usage' => $result['usage'] ?? null,
'limits' => $result['limits'] ?? null,
'context' => $context
]);
} catch (Throwable $e) {
raijuAiAudit('copilot-suggest', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-translate':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('suggestField')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('translate', 6, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$instruction = trim((string)($_POST['instruction'] ?? ''));
$fieldRef = trim((string)($_POST['fieldRef'] ?? ''));
$targetLang = strtolower(trim((string)($_POST['targetLang'] ?? '')));
if ($contentPage === '') {
jsonResponse(false, null, 'Content page is required');
}
try {
$sourceLang = preg_match('/^([a-z]{2})_/', $contentPage, $langMatch) ? $langMatch[1] : '';
if ($targetLang === '') {
$targetLang = raijuCopilotDetectTargetLanguage($instruction, $sourceLang);
}
if ($targetLang === '' || $targetLang === $sourceLang) {
jsonResponse(false, null, 'Please name the target language for the translation (for example "translate this page to English").');
}
if (!array_key_exists($targetLang, raijuCopilotSiteLanguages())) {
jsonResponse(false, null, 'The language "' . $targetLang . '" is not configured for this site.');
}
$targetContentPage = raijuCopilotTranslationCounterpart($contentPage, $targetLang);
if ($targetContentPage === '') {
jsonResponse(false, null, 'Translation drafts are only available for regular pages.');
}
$settings = raijuAiLoadSettings(true);
$targetContext = raijuCopilotBuildContext($targetContentPage, $settings);
if (empty($targetContext['page']['exists'])) {
jsonResponse(false, null, 'The ' . strtoupper($targetLang) . ' version of this page does not exist yet. Create it in the dashboard first.');
}
$sourceData = raijuCopilotLoadPageData($contentPage);
$targetData = raijuCopilotLoadPageData($targetContentPage);
$fields = raijuCopilotTranslationFields($targetContext, $sourceData, $fieldRef !== '' ? $fieldRef : null);
if (!$fields) {
jsonResponse(false, null, 'No translatable fields with source content were found for this page.');
}
$prompt = raijuCopilotBuildTranslatePrompt($fields, $sourceLang, $targetLang, substr($instruction, 0, 1200));
$result = raijuAiGenerateText($prompt, [
'feature' => 'backendAssistant',
'maxOutputTokens' => 3000,
'temperature' => 0.2,
'system' => 'You translate website content faithfully for raiju CMS. Return strict JSON only.'
]);
$raw = raijuCopilotExtractJsonObject((string)($result['text'] ?? ''));
$proposals = raijuCopilotValidateProposals($raw, $targetContext, $targetData, count($fields), $safetyReport);
if (!$proposals && ($safetyReport['blocked'] ?? 0) > 0) {
throw new RuntimeException(t('copilot.safety_blocked'));
}
foreach ($proposals as $index => $proposal) {
$proposals[$index]['contentPage'] = $targetContentPage;
$proposals[$index]['label'] = strtoupper($targetLang) . ' · ' . (string)($proposal['label'] ?? $proposal['path']);
}
raijuAiAudit('copilot-translate', true, [
'contentPage' => $contentPage,
'targetContentPage' => $targetContentPage,
'targetLang' => $targetLang,
'proposalCount' => count($proposals),
'proposals' => dashboardCopilotProposalAuditSummary($proposals)
]);
jsonResponse(true, [
'proposals' => $proposals,
'targetContentPage' => $targetContentPage,
'targetLang' => $targetLang,
'usage' => $result['usage'] ?? null,
'limits' => $result['limits'] ?? null
]);
} catch (Throwable $e) {
raijuAiAudit('copilot-translate', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-format-html':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('suggestField')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('format-html', 20, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$fieldRef = trim((string)($_POST['fieldRef'] ?? ''));
$format = trim((string)($_POST['format'] ?? ''));
$instruction = trim((string)($_POST['instruction'] ?? ''));
if ($contentPage === '' || $fieldRef === '' || $format === '') {
jsonResponse(false, null, 'Content page, HTML field, and format action are required');
}
try {
$settings = raijuAiLoadSettings(true);
$context = raijuCopilotBuildContext($contentPage, $settings);
$proposal = raijuCopilotBuildHtmlFormatProposal($contentPage, $fieldRef, $format, $instruction);
raijuAiAudit('copilot-format-html', true, [
'contentPage' => $contentPage,
'fieldRef' => $fieldRef,
'format' => raijuCopilotNormalizeFormatOperation($format),
'proposals' => dashboardCopilotProposalAuditSummary([$proposal])
]);
jsonResponse(true, [
'proposals' => [$proposal],
'context' => $context
]);
} catch (Throwable $e) {
raijuAiAudit('copilot-format-html', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage, 'fieldRef' => $fieldRef]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-visibility':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('toggleVisibility')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('visibility', 20, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$fieldRef = trim((string)($_POST['fieldRef'] ?? ''));
$visibilityAction = trim((string)($_POST['visibilityAction'] ?? $_POST['actionValue'] ?? ''));
$instruction = trim((string)($_POST['instruction'] ?? ''));
if ($contentPage === '' || $fieldRef === '' || $visibilityAction === '') {
jsonResponse(false, null, 'Content page, field, and visibility action are required');
}
try {
$settings = raijuAiLoadSettings(true);
$context = raijuCopilotBuildContext($contentPage, $settings);
$proposal = raijuCopilotBuildVisibilityProposal($contentPage, $fieldRef, $visibilityAction, $instruction);
raijuAiAudit('copilot-visibility', true, [
'contentPage' => $contentPage,
'fieldRef' => $fieldRef,
'visibilityAction' => raijuCopilotNormalizeVisibilityAction($visibilityAction),
'proposals' => dashboardCopilotProposalAuditSummary([$proposal])
]);
jsonResponse(true, [
'proposals' => [$proposal],
'context' => $context
]);
} catch (Throwable $e) {
raijuAiAudit('copilot-visibility', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage, 'fieldRef' => $fieldRef]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-apply':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('applyField')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!dashboardCopilotConfirmed()) {
jsonResponse(false, null, 'AI write action requires explicit confirmation');
}
try {
raijuCopilotAssertBurstLimit('apply', 30, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$path = trim((string)($_POST['path'] ?? ''));
$value = (string)($_POST['value'] ?? '');
$altValue = (string)($_POST['altValue'] ?? '');
$currentHash = trim((string)($_POST['currentHash'] ?? ''));
$allowedValueHashes = json_decode((string)($_POST['allowedValueHashes'] ?? '[]'), true);
if (!is_array($allowedValueHashes)) {
$allowedValueHashes = [];
}
$proposalSignature = trim((string)($_POST['proposalSignature'] ?? ''));
if ($contentPage === '' || $path === '') {
jsonResponse(false, null, 'Missing field target');
}
try {
$applied = raijuCopilotApplyFieldUpdate($contentPage, $path, $value, $currentHash, $altValue, $allowedValueHashes, $proposalSignature);
$filepath = function_exists('raijuCopilotContentPath') ? raijuCopilotContentPath($contentPage) : '';
if ($filepath === '') {
throw new RuntimeException('Unsupported AI field update target.');
}
$backupName = raijuPageIsValidContentKey($contentPage)
? dashboardCopilotCreatePageBackup($contentPage)
: '';
$written = raijuJsonAtomicWrite($filepath, $applied['data']);
if ($written === false) {
throw new RuntimeException('Could not save AI field update.');
}
raijuAiAudit('copilot-apply', true, [
'contentPage' => $contentPage,
'path' => $path,
'type' => $applied['field']['type'] ?? '',
'oldHash' => hash('sha256', (string)$applied['oldValue']),
'newHash' => hash('sha256', (string)$applied['newValue'])
]);
$response = [
'contentPage' => $contentPage,
'path' => $path,
'value' => $applied['newValue'],
'altValue' => $applied['altValue'] ?? '',
'lastModified' => $applied['data']['lastModified'] ?? null,
];
if ($backupName !== '') {
$response['undo'] = [
'contentPage' => $contentPage,
'backup' => $backupName,
'path' => $path,
'undoSignature' => dashboardCopilotUndoSignature($contentPage, $backupName, $path)
];
}
if (($pageParts = raijuPageParseContentKey($contentPage)) !== null) {
$response['seoHealth'] = buildPageSeoHealth($pageParts['lang'], $pageParts['path'], $applied['data']);
}
jsonResponse(true, $response, 'AI field update applied');
} catch (Throwable $e) {
raijuAiAudit('copilot-apply', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage, 'path' => $path]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-apply-visibility':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('toggleVisibility')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!dashboardCopilotConfirmed()) {
jsonResponse(false, null, 'AI visibility action requires explicit confirmation');
}
try {
raijuCopilotAssertBurstLimit('apply-visibility', 30, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$path = trim((string)($_POST['path'] ?? ''));
$visibilityAction = trim((string)($_POST['value'] ?? $_POST['visibilityAction'] ?? ''));
$currentHash = trim((string)($_POST['currentHash'] ?? ''));
$visibilitySignature = trim((string)($_POST['visibilitySignature'] ?? ''));
if ($contentPage === '' || $path === '' || $visibilityAction === '') {
jsonResponse(false, null, 'Missing visibility target');
}
try {
$applied = raijuCopilotApplyVisibilityUpdate($contentPage, $path, $visibilityAction, $currentHash, $visibilitySignature);
$filepath = CONTENT_PATH . $contentPage . '.json';
$backupName = dashboardCopilotCreatePageBackup($contentPage);
$written = raijuJsonAtomicWrite($filepath, $applied['data']);
if ($written === false) {
throw new RuntimeException('Could not save AI visibility update.');
}
raijuAiAudit('copilot-apply-visibility', true, [
'contentPage' => $contentPage,
'path' => $path,
'hiddenPath' => $applied['hiddenPath'] ?? '',
'oldHidden' => !empty($applied['oldHidden']),
'newHidden' => !empty($applied['newHidden'])
]);
$undoPath = (string)($applied['hiddenPath'] ?? ($path . '__hidden'));
jsonResponse(true, [
'contentPage' => $contentPage,
'path' => $path,
'hiddenPath' => $undoPath,
'value' => $applied['newValue'],
'hidden' => !empty($applied['newHidden']),
'lastModified' => $applied['data']['lastModified'] ?? null,
'undo' => [
'contentPage' => $contentPage,
'backup' => $backupName,
'path' => $undoPath,
'undoSignature' => dashboardCopilotUndoSignature($contentPage, $backupName, $undoPath)
]
], 'AI visibility update applied');
} catch (Throwable $e) {
raijuAiAudit('copilot-apply-visibility', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage, 'path' => $path]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-undo':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('undoField')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!dashboardCopilotConfirmed()) {
jsonResponse(false, null, 'AI undo action requires explicit confirmation');
}
try {
raijuCopilotAssertBurstLimit('undo', 20, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$backup = trim((string)($_POST['backup'] ?? ''));
$undoPath = trim((string)($_POST['path'] ?? ''));
$undoSignature = trim((string)($_POST['undoSignature'] ?? ''));
if (!validatePageName($contentPage) || !validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid undo target');
}
if ($undoPath === '' || $undoSignature === '' || !hash_equals(dashboardCopilotUndoSignature($contentPage, $backup, $undoPath), $undoSignature)) {
jsonResponse(false, null, 'Undo signature is missing or invalid');
}
$expectedPrefix = $contentPage . '_';
if (!str_starts_with($backup, $expectedPrefix)) {
jsonResponse(false, null, 'Backup does not belong to this page');
}
$backupPath = BACKUP_PATH . $backup;
$filepath = CONTENT_PATH . $contentPage . '.json';
if (!is_file($backupPath) || !is_file($filepath)) {
jsonResponse(false, null, 'Undo backup not found');
}
try {
$currentBackup = dashboardCopilotCreatePageBackup($contentPage, false);
if (!copy($backupPath, $filepath)) {
throw new RuntimeException('Could not restore AI backup.');
}
$restored = json_decode((string)file_get_contents($filepath), true);
if (!is_array($restored)) {
throw new RuntimeException('Restored backup is not valid JSON.');
}
$restored['lastModified'] = date('c');
if (file_put_contents($filepath, json_encode($restored, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX) === false) {
throw new RuntimeException('Could not update restored page metadata.');
}
cleanupOldBackups($contentPage);
raijuAiAudit('copilot-undo', true, [
'contentPage' => $contentPage,
'backup' => $backup,
'currentBackup' => $currentBackup
]);
$response = [
'contentPage' => $contentPage,
'backup' => $backup,
'lastModified' => $restored['lastModified'] ?? null
];
if (($pageParts = raijuPageParseContentKey($contentPage)) !== null) {
$response['seoHealth'] = buildPageSeoHealth($pageParts['lang'], $pageParts['path'], $restored);
}
jsonResponse(true, $response, 'AI change restored from backup');
} catch (Throwable $e) {
raijuAiAudit('copilot-undo', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage, 'backup' => $backup]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-draft-content':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('createPage') && !raijuCopilotCan('createNews') && !raijuCopilotCan('createEvent')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('draft-content', 8, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$instruction = trim((string)($_POST['instruction'] ?? ''));
$contentType = trim((string)($_POST['contentType'] ?? ''));
if ($instruction === '') {
jsonResponse(false, null, 'Instruction is required');
}
try {
$settings = raijuAiLoadSettings(true);
$context = raijuCopilotBuildContext(trim((string)($_POST['contentPage'] ?? '')), $settings);
$existingDraftPayload = json_decode((string)($_POST['existingDraft'] ?? '[]'), true);
$existingDraft = [];
if (is_array($existingDraftPayload) && isset($existingDraftPayload['contentType'], $existingDraftPayload['draft'])) {
$existingDraft = [
'contentType' => (string)$existingDraftPayload['contentType'],
'missing' => array_values(array_filter(array_map('strval', is_array($existingDraftPayload['missing'] ?? null) ? $existingDraftPayload['missing'] : []))),
'draft' => is_array($existingDraftPayload['draft'] ?? null) ? $existingDraftPayload['draft'] : []
];
if ($contentType === '') {
$contentType = $existingDraft['contentType'];
}
}
$prompt = raijuCopilotBuildCreatePrompt($context, substr($instruction, 0, 2400), $contentType, $existingDraft);
$result = raijuAiGenerateText($prompt, [
'feature' => 'backendAssistant',
// Full content drafts are quality-sensitive: use the chat
// model instead of the (typically cheaper) text model.
'model' => (string)($settings['chatModel'] ?? ''),
'purpose' => 'chat',
'maxOutputTokens' => 1200,
'temperature' => 0.15,
'system' => 'You extract safe raiju CMS content drafts. Return strict JSON only.'
]);
$raw = raijuCopilotExtractJsonObject((string)($result['text'] ?? ''));
$draft = raijuCopilotSignCreateDraft(raijuCopilotNormalizeCreateDraft($raw, $context));
raijuAiAudit('copilot-draft-content', true, [
'contentType' => $draft['contentType'],
'canCreate' => $draft['canCreate'],
'missingCount' => count($draft['missing']),
'missing' => $draft['missing'],
'draftHash' => (string)($draft['draftHash'] ?? ''),
'signed' => !empty($draft['draftSignature'])
]);
jsonResponse(true, [
'draft' => $draft,
'usage' => $result['usage'] ?? null,
'limits' => $result['limits'] ?? null
]);
} catch (Throwable $e) {
raijuAiAudit('copilot-draft-content', false, ['message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-create-content':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!dashboardCopilotConfirmed()) {
jsonResponse(false, null, 'AI content creation requires explicit confirmation');
}
try {
raijuCopilotAssertBurstLimit('create-content', 15, 300);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$draftPayload = json_decode((string)($_POST['draft'] ?? ''), true);
if (!is_array($draftPayload)) {
jsonResponse(false, null, 'Invalid draft JSON');
}
try {
$type = (string)($draftPayload['contentType'] ?? '');
$permissionMap = ['page' => 'createPage', 'news' => 'createNews', 'event' => 'createEvent'];
if (empty($permissionMap[$type]) || !raijuCopilotCan($permissionMap[$type])) {
throw new RuntimeException('You do not have permission to create this content type.');
}
if (!raijuCopilotVerifyCreateDraftSignature($draftPayload)) {
throw new RuntimeException('Draft signature is missing or invalid. Generate a fresh preview before creating content.');
}
$draft = is_array($draftPayload['draft'] ?? null) ? $draftPayload['draft'] : [];
$expectedHash = trim((string)($draftPayload['draftHash'] ?? ''));
$actualHash = hash('sha256', json_encode([$type, $draft], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if ($expectedHash === '' || !hash_equals($expectedHash, $actualHash)) {
throw new RuntimeException('Draft changed after preview. Generate a fresh draft before creating content.');
}
$created = raijuCopilotBuildCreatedContent($type, $draft);
if ($type === 'event') {
$eventsPath = defined('EVENTS_PATH') ? EVENTS_PATH : dirname(CONTENT_PATH) . '/events.json';
$duplicate = false;
$saved = raijuJsonUpdate($eventsPath, static function (array &$data) use ($created, $eventsPath, &$duplicate) {
if (!is_array($data['events'] ?? null)) {
$data['events'] = [];
}
foreach ($data['events'] as $event) {
if (($event['id'] ?? '') === ($created['id'] ?? '')) {
$duplicate = true;
return false;
}
}
if (is_file($eventsPath)) {
copy($eventsPath, BACKUP_PATH . 'events_' . date('Y-m-d_His') . '.json');
}
$data['events'][] = $created;
$data['lastModified'] = date('c');
return true;
}, ['events' => []]);
if ($duplicate) {
throw new RuntimeException('An event with this ID already exists.');
}
if (!$saved) {
throw new RuntimeException('Could not save event draft.');
}
$response = [
'contentType' => 'event',
'id' => $created['id'],
'hidden' => true,
'publishable' => raijuCopilotCan('publishEvent'),
'adminUrl' => dashboardCopilotAdminUrl('events')
];
} elseif ($type === 'news') {
$newsDir = dirname(CONTENT_PATH) . '/news/';
if (!is_dir($newsDir)) {
mkdir($newsDir, 0755, true);
}
$filepath = $newsDir . $created['id'] . '.json';
if (is_file($filepath)) {
throw new RuntimeException('A news post with this ID already exists.');
}
if (file_put_contents($filepath, json_encode($created, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
throw new RuntimeException('Could not save news draft.');
}
$response = [
'contentType' => 'news',
'id' => $created['id'],
'hidden' => true,
'publishable' => raijuCopilotCan('publishNews'),
'adminUrl' => dashboardCopilotAdminUrl('news'),
'publicUrl' => dashboardCopilotNewsUrl($created['id'], (string)($created['lang'] ?? (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en')))
];
} elseif ($type === 'page') {
$pageName = $created['pageName'] ?? '';
if (!validatePageName($pageName)) {
throw new RuntimeException('Invalid page draft name.');
}
$filepath = CONTENT_PATH . $pageName . '.json';
if (is_file($filepath)) {
throw new RuntimeException('A page with this slug already exists.');
}
if (file_put_contents($filepath, json_encode($created['content'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
throw new RuntimeException('Could not save page draft.');
}
$response = [
'contentType' => 'page',
'id' => $pageName,
'private' => true,
'publishable' => raijuCopilotCan('publishPage'),
'adminUrl' => dashboardCopilotAdminUrl('page/' . $pageName),
'publicUrl' => dashboardCopilotPageUrl($pageName)
];
} else {
throw new RuntimeException('Unsupported content type.');
}
raijuAiAudit('copilot-create-content', true, $response);
jsonResponse(true, $response, 'AI content draft created');
} catch (Throwable $e) {
raijuAiAudit('copilot-create-content', false, ['message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-publish-content':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!dashboardCopilotConfirmed()) {
jsonResponse(false, null, 'AI publish action requires explicit confirmation');
}
try {
raijuCopilotAssertBurstLimit('publish-content', 15, 300);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$type = trim((string)($_POST['contentType'] ?? ''));
$id = trim((string)($_POST['id'] ?? ''));
if (!in_array($type, ['page', 'news', 'event'], true) || $id === '') {
jsonResponse(false, null, 'Invalid publish target');
}
$permissionMap = ['page' => 'publishPage', 'news' => 'publishNews', 'event' => 'publishEvent'];
if (!raijuCopilotCan($permissionMap[$type])) {
jsonResponse(false, null, 'Forbidden');
}
try {
if ($type === 'page') {
if (!validatePageName($id)) {
throw new RuntimeException('Invalid page name.');
}
$filepath = CONTENT_PATH . $id . '.json';
if (!is_file($filepath)) {
throw new RuntimeException('Page not found.');
}
$backupName = dashboardCopilotCreatePageBackup($id);
$data = json_decode((string)file_get_contents($filepath), true);
if (!is_array($data)) {
throw new RuntimeException('Invalid page JSON.');
}
$data = raijuCopilotPublishPageData($data);
if (file_put_contents($filepath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
throw new RuntimeException('Could not publish page.');
}
$response = [
'contentType' => 'page',
'id' => $id,
'private' => false,
'published' => true,
'backup' => $backupName,
'adminUrl' => dashboardCopilotAdminUrl('page/' . $id),
'publicUrl' => dashboardCopilotPageUrl($id)
];
} elseif ($type === 'news') {
if (!preg_match('/^[a-z0-9][a-z0-9-]*$/', $id)) {
throw new RuntimeException('Invalid news ID.');
}
$newsDir = dirname(CONTENT_PATH) . '/news/';
$filepath = $newsDir . $id . '.json';
if (!is_file($filepath)) {
throw new RuntimeException('News post not found.');
}
$backupPath = BACKUP_PATH . 'news_' . $id . '_' . date('Y-m-d_His') . '.json';
copy($filepath, $backupPath);
$post = json_decode((string)file_get_contents($filepath), true);
if (!is_array($post)) {
throw new RuntimeException('Invalid news JSON.');
}
$post = raijuCopilotPublishNewsData($post);
if (file_put_contents($filepath, json_encode($post, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
throw new RuntimeException('Could not publish news post.');
}
$response = [
'contentType' => 'news',
'id' => $id,
'hidden' => false,
'published' => true,
'adminUrl' => dashboardCopilotAdminUrl('news'),
'publicUrl' => dashboardCopilotNewsUrl($id, (string)($post['lang'] ?? (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en')))
];
} else {
$eventsPath = defined('EVENTS_PATH') ? EVENTS_PATH : dirname(CONTENT_PATH) . '/events.json';
if (!is_file($eventsPath)) {
throw new RuntimeException('Events file not found.');
}
$saved = raijuJsonUpdate($eventsPath, static function (array &$data) use ($eventsPath, $id) {
if (!is_array($data['events'] ?? null)) {
throw new RuntimeException('Invalid events JSON.');
}
copy($eventsPath, BACKUP_PATH . 'events_' . date('Y-m-d_His') . '.json');
$data = raijuCopilotPublishEventData($data, $id);
return true;
});
if (!$saved) {
throw new RuntimeException('Could not publish event.');
}
$response = [
'contentType' => 'event',
'id' => $id,
'hidden' => false,
'published' => true,
'adminUrl' => dashboardCopilotAdminUrl('events')
];
}
raijuAiAudit('copilot-publish-content', true, $response);
jsonResponse(true, $response, 'AI-created content published');
} catch (Throwable $e) {
raijuAiAudit('copilot-publish-content', false, ['message' => $e->getMessage(), 'contentType' => $type, 'id' => $id]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-copilot-generate-image':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuCopilotCan('generateImage')) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
raijuCopilotAssertBurstLimit('generate-image', 6, 300);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
$fieldRef = trim((string)($_POST['fieldRef'] ?? ''));
$instruction = trim((string)($_POST['instruction'] ?? ''));
$imageMode = trim((string)($_POST['imageMode'] ?? ''));
if (!in_array($imageMode, ['generate', 'edit'], true)) {
$imageMode = !empty($_POST['useCurrentAsReference']) ? 'edit' : 'generate';
}
$useCurrentAsReference = $imageMode === 'edit' && !empty($_POST['useCurrentAsReference']);
if ($contentPage === '' || $fieldRef === '' || $instruction === '') {
jsonResponse(false, null, 'Content page, image field, and prompt are required');
}
try {
$settings = raijuAiLoadSettings(false);
$imageService = raijuAiImageServiceResolve($settings);
raijuAiEnsureImageReady($settings, $imageService);
if ($imageService['model'] === '') {
throw new RuntimeException('Image model is missing.');
}
$publicSettings = raijuAiLoadSettings(true);
$context = raijuCopilotBuildContext($contentPage, $publicSettings);
if (empty($context['page']['exists'])) {
jsonResponse(false, null, 'Content page not found');
}
$fields = raijuCopilotAllowedImageFields($context, $fieldRef);
if (!$fields) {
jsonResponse(false, null, 'Target field does not accept AI image generation.');
}
$field = $fields[0];
$pageData = raijuCopilotLoadPageData($contentPage);
$current = getNestedValue($pageData, $field['path']);
$currentPath = is_array($current) ? (string)($current['src'] ?? '') : (string)$current;
$referenceMediaPaths = [];
$referenceImagePaths = [];
$referenceImageNames = [];
$temporaryReferencePaths = [];
if ($useCurrentAsReference && trim($currentPath) !== '') {
if (raijuCopilotIsExternalImageUrl($currentPath)) {
$externalReference = raijuCopilotDownloadExternalReferenceImage($currentPath);
$referenceImagePaths[] = $externalReference;
$referenceImageNames[] = basename(parse_url($currentPath, PHP_URL_PATH) ?: 'external-reference-image');
$temporaryReferencePaths[] = $externalReference;
} else {
$referenceMediaPaths[] = raijuCopilotNormalizeImagePath($currentPath);
}
}
$size = trim((string)($_POST['size'] ?? 'auto'));
if (!in_array($size, ['auto', '1024x1024', '1536x1024', '1024x1536'], true)) {
$size = 'auto';
}
$count = max(1, min(4, (int)($_POST['count'] ?? 3)));
$outputFormat = strtolower(trim((string)($_POST['outputFormat'] ?? 'webp')));
if (!in_array($outputFormat, ['webp', 'png', 'jpeg', 'jpg'], true)) {
$outputFormat = 'webp';
}
if ($outputFormat === 'jpg') {
$outputFormat = 'jpeg';
}
$quality = strtolower(trim((string)($_POST['quality'] ?? 'auto')));
if (!in_array($quality, ['auto', 'low', 'medium', 'high'], true)) {
$quality = 'auto';
}
$prompt = raijuCopilotBuildImagePrompt($context, $field, substr($instruction, 0, 1800), $imageMode);
$job = raijuAiCreateImageJob('copilot', [
'contentPage' => $contentPage,
'fieldRef' => $field['id'],
'instruction' => $instruction,
'prompt' => $prompt,
'imageMode' => $imageMode,
'options' => [
'size' => $size,
'aspectRatio' => 'auto',
'imageScale' => $_POST['imageScale'] ?? 2048,
'count' => $count,
'outputFormat' => $outputFormat,
'quality' => $quality,
'moderation' => 'auto',
'outputCompression' => $_POST['outputCompression'] ?? 100,
'referenceImagePaths' => $referenceImagePaths,
'referenceImageNames' => $referenceImageNames,
'referenceMediaPaths' => $referenceMediaPaths,
'filenameHint' => raijuCopilotSlugify($context['page']['slug'] . '-' . $field['path'], 'copilot-image')
]
]);
try {
raijuAiAudit('copilot-generate-image-queued', true, [
'jobId' => $job['id'],
'contentPage' => $contentPage,
'path' => $field['path'],
'imageMode' => $imageMode,
'requestedCount' => $count
]);
jsonResponse(true, [
'job' => $job,
'context' => $context
], 'Image generation queued');
} finally {
foreach ($temporaryReferencePaths as $temporaryReferencePath) {
@unlink($temporaryReferencePath);
}
}
} catch (Throwable $e) {
raijuAiAudit('copilot-generate-image', false, ['message' => $e->getMessage(), 'contentPage' => $contentPage, 'fieldRef' => $fieldRef]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-generate-text':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$prompt = trim((string)($_POST['prompt'] ?? ''));
if ($prompt === '') {
jsonResponse(false, null, 'Prompt is required');
}
try {
$result = raijuAiGenerateText($prompt, [
'feature' => 'seoTextGeneration',
'maxOutputTokens' => $_POST['maxOutputTokens'] ?? null
]);
jsonResponse(true, $result);
} catch (Throwable $e) {
raijuAiAudit('text', false, ['message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-generate-seo':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$context = json_decode((string)($_POST['context'] ?? '{}'), true);
if (!is_array($context)) {
jsonResponse(false, null, 'Invalid page context JSON');
}
$field = trim((string)($_POST['field'] ?? 'all'));
$allowedFields = ['all', 'title', 'description', 'answerSummary', 'ogTitle', 'ogDescription'];
if (!in_array($field, $allowedFields, true)) {
jsonResponse(false, null, 'Invalid SEO field');
}
$context = [
'lang' => substr((string)($context['lang'] ?? ''), 0, 8),
'slug' => substr((string)($context['slug'] ?? ''), 0, 120),
'title' => substr((string)($context['title'] ?? ''), 0, 180),
'description' => substr((string)($context['description'] ?? ''), 0, 500),
'seo' => is_array($context['seo'] ?? null) ? array_intersect_key($context['seo'], array_flip(['title', 'description', 'answerSummary', 'ogTitle', 'ogDescription'])) : [],
'contentText' => substr((string)($context['contentText'] ?? ''), 0, 9000)
];
$fieldInstruction = $field === 'all'
? 'Fill every JSON field.'
: 'Fill only the JSON field "' . $field . '" and still return a JSON object with that single key.';
$prompt = "Create practical SEO/AEO metadata for this raiju CMS page.\n"
. "Return strict JSON only, no Markdown and no prose.\n"
. "Allowed keys: title, description, answerSummary, ogTitle, ogDescription.\n"
. "Constraints: title <= 70 characters; description <= 160 characters; answerSummary <= 320 characters; ogTitle <= 70 characters; ogDescription <= 180 characters.\n"
. "Use the page language if possible. Do not invent facts, names, offers, prices, certifications, or locations that are not implied by the content.\n"
. $fieldInstruction . "\n\n"
. "Page context:\n" . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
try {
$result = raijuAiGenerateText($prompt, [
'feature' => 'seoTextGeneration',
'maxOutputTokens' => 900,
'temperature' => 0.25,
'system' => 'You generate SEO and answer-engine metadata for a CMS. Return valid compact JSON only.'
]);
$text = trim((string)($result['text'] ?? ''));
$json = $text;
if (preg_match('/```(?:json)?\s*(.*?)```/is', $text, $m)) {
$json = trim($m[1]);
} elseif (preg_match('/\{.*\}/s', $text, $m)) {
$json = $m[0];
}
$data = json_decode($json, true);
if (!is_array($data)) {
throw new RuntimeException('AI did not return valid SEO JSON.');
}
$clean = [];
$limits = [
'title' => 90,
'description' => 180,
'answerSummary' => 420,
'ogTitle' => 90,
'ogDescription' => 220
];
foreach ($limits as $key => $limit) {
if (($field === 'all' || $field === $key) && isset($data[$key])) {
$clean[$key] = substr(trim((string)$data[$key]), 0, $limit);
}
}
jsonResponse(true, ['fields' => $clean, 'limits' => $result['limits'] ?? null]);
} catch (Throwable $e) {
raijuAiAudit('seo-generate', false, ['message' => $e->getMessage(), 'field' => $field]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-list-models':
// The models the saved provider offers, for the dropdowns of the settings form (admins only).
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
try {
$current = raijuAiLoadSettings(false);
jsonResponse(true, ['provider' => (string)$current['provider'], 'models' => raijuAiListProviderModels($current)]);
} catch (Throwable $e) {
jsonResponse(false, null, 'Could not load the model list: ' . $e->getMessage());
}
break;
case 'ai-openrouter-models':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
jsonResponse(true, raijuApiOpenRouterModels(!empty($_POST['refresh']) || !empty($_GET['refresh'])));
} catch (Throwable $e) {
jsonResponse(false, null, 'Could not load the OpenRouter model list: ' . $e->getMessage());
}
break;
case 'ai-image-models':
// The image models fal.ai or Comet list themselves, for the model suggestions (cached for a day).
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
try {
jsonResponse(true, raijuAiImageLiveModels(raijuAiLoadSettings(false), (string)($_POST['service'] ?? ''), !empty($_POST['refresh'])));
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-content-audit':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$rows = [];
foreach (glob(rtrim(CONTENT_PATH, '/') . '/*.json') ?: [] as $file) {
$name = pathinfo($file, PATHINFO_FILENAME);
if (!raijuPageIsValidContentKey($name)) {
continue;
}
$data = dashboardReadJsonFile($file);
if (!$data) {
continue;
}
$description = trim((string)(($data['seo']['description'] ?? '') ?: ($data['description'] ?? '')));
$rows[] = [
'contentPage' => $name,
'lang' => (string)($data['lang'] ?? substr($name, 0, 2)),
'title' => substr((string)($data['title'] ?? $name), 0, 120),
'descriptionStatus' => raijuAuditDescriptionStatus($description),
'descriptionLength' => function_exists('mb_strlen') ? mb_strlen($description, 'UTF-8') : strlen($description),
'missingAlt' => raijuAuditCountMissingAlt($data['sections'] ?? [])
];
}
usort($rows, function (array $a, array $b): int {
$rank = fn(array $row): int => ($row['descriptionStatus'] === 'ok' ? 0 : 2) + ($row['missingAlt'] > 0 ? 1 : 0);
return $rank($b) <=> $rank($a) ?: strcmp($a['contentPage'], $b['contentPage']);
});
jsonResponse(true, ['pages' => $rows]);
break;
case 'ai-content-audit-suggest':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
if (!raijuPageIsValidContentKey($contentPage)) {
jsonResponse(false, null, 'Invalid content page');
}
$data = dashboardReadJsonFile(CONTENT_PATH . $contentPage . '.json');
if (!$data) {
jsonResponse(false, null, 'Content page not found');
}
try {
$lang = (string)($data['lang'] ?? substr($contentPage, 0, 2));
$prompt = "Write one SEO meta description (45-160 characters) in the language \"{$lang}\" for this website page.\n"
. "Return only the description text without quotes, labels, or Markdown.\n"
. "Do not invent facts, names, offers, prices, certifications, or locations that are not implied by the content.\n\n"
. 'Page title: ' . (string)($data['title'] ?? $contentPage) . "\n"
. "Page content:\n" . raijuAuditPageText($data);
$result = raijuAiGenerateText($prompt, [
'feature' => 'seoTextGeneration',
'maxOutputTokens' => 220,
'temperature' => 0.3
]);
$description = trim((string)($result['text'] ?? ''), " \t\n\r\0\x0B\"'");
$description = substr(preg_replace('/\s+/', ' ', strip_tags($description)) ?? '', 0, 300);
if ($description === '') {
throw new RuntimeException('AI returned no description.');
}
jsonResponse(true, [
'contentPage' => $contentPage,
'description' => $description,
'limits' => $result['limits'] ?? null
]);
} catch (Throwable $e) {
raijuAiAudit('content-audit-suggest', false, ['contentPage' => $contentPage, 'message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-content-audit-apply':
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!dashboardCopilotConfirmed()) {
jsonResponse(false, null, 'AI write action requires explicit confirmation');
}
$contentPage = trim((string)($_POST['contentPage'] ?? ''));
if (!raijuPageIsValidContentKey($contentPage)) {
jsonResponse(false, null, 'Invalid content page');
}
$description = trim((string)($_POST['description'] ?? ''));
$description = substr(preg_replace('/[\x00-\x1F\x7F]/', ' ', strip_tags($description)) ?? '', 0, 300);
if ($description === '') {
jsonResponse(false, null, 'Description is required');
}
try {
$filepath = CONTENT_PATH . $contentPage . '.json';
$data = dashboardReadJsonFile($filepath);
if (!$data) {
jsonResponse(false, null, 'Content page not found');
}
$backup = dashboardCopilotCreatePageBackup($contentPage);
$data['description'] = $description;
if (is_array($data['seo'] ?? null) && array_key_exists('description', $data['seo'])) {
$data['seo']['description'] = $description;
}
$data['lastModified'] = date('c');
if (file_put_contents($filepath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
throw new RuntimeException('Could not save the page.');
}
raijuAiAudit('content-audit-apply', true, [
'contentPage' => $contentPage,
'backup' => $backup,
'descriptionHash' => hash('sha256', $description)
]);
jsonResponse(true, ['contentPage' => $contentPage, 'backup' => $backup]);
} catch (Throwable $e) {
raijuAiAudit('content-audit-apply', false, ['contentPage' => $contentPage, 'message' => $e->getMessage()]);
jsonResponse(false, null, $e->getMessage());
}
break;
case 'ai-generate-image':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$prompt = trim((string)($_POST['prompt'] ?? ''));
try {
$referenceImagePaths = [];
$referenceImageNames = [];
if (!empty($_FILES['referenceImages']['tmp_name']) && is_array($_FILES['referenceImages']['tmp_name'])) {
foreach ($_FILES['referenceImages']['tmp_name'] as $idx => $tmpName) {
if (is_string($tmpName) && $tmpName !== '') {
$referenceImagePaths[] = $tmpName;
$referenceImageNames[] = (string)($_FILES['referenceImages']['name'][$idx] ?? 'reference-image');
}
}
} elseif (!empty($_FILES['referenceImage']['tmp_name'])) {
$referenceImagePaths[] = (string)$_FILES['referenceImage']['tmp_name'];
$referenceImageNames[] = (string)($_FILES['referenceImage']['name'] ?? 'reference-image');
}
$referenceMediaPaths = $_POST['referenceMediaPaths'] ?? ($_POST['referenceMediaPath'] ?? []);
if (!is_array($referenceMediaPaths)) {
$referenceMediaPaths = [$referenceMediaPaths];
}
$imageOptions = [
'size' => $_POST['size'] ?? '1024x1024',
'aspectRatio' => $_POST['aspectRatio'] ?? 'auto',
'imageScale' => $_POST['imageScale'] ?? 2048,
'model' => $_POST['model'] ?? null,
'count' => $_POST['count'] ?? 1,
'outputFormat' => $_POST['outputFormat'] ?? 'webp',
'quality' => $_POST['quality'] ?? 'auto',
'moderation' => $_POST['moderation'] ?? 'auto',
'outputCompression' => $_POST['outputCompression'] ?? 100,
'referenceImagePaths' => $referenceImagePaths,
'referenceImageNames' => $referenceImageNames,
'referenceMediaPaths' => $referenceMediaPaths,
'filenameHint' => $_POST['filenameHint'] ?? 'ai-image'
];
$job = raijuAiCreateImageJob('dashboard', [
'prompt' => $prompt,
'options' => $imageOptions
]);
raijuAiAudit('image-queued', true, [
'jobId' => $job['id'],
'model' => (string)($imageOptions['model'] ?? ''),
'count' => (int)($imageOptions['count'] ?? 1)
]);
jsonResponse(true, ['job' => $job], 'Image generation queued');
} catch (Throwable $e) {
raijuAiAudit('image', false, ['message' => $e->getMessage()]);
$settings = raijuAiLoadSettings(false);
raijuAiRecordImageHistory([
'status' => 'error',
'model' => (string)($_POST['model'] ?? raijuAiEffectiveImageModel($settings)),
'prompt' => $prompt,
'size' => (string)($_POST['size'] ?? ''),
'aspectRatio' => (string)($_POST['aspectRatio'] ?? ''),
'quality' => (string)($_POST['quality'] ?? ''),
'format' => (string)($_POST['outputFormat'] ?? ''),
'moderation' => (string)($_POST['moderation'] ?? ''),
'compression' => (int)($_POST['outputCompression'] ?? 0),
'count' => (int)($_POST['count'] ?? 0),
'referenceImages' => raijuAiPublicReferenceList([
'referenceImageNames' => $referenceImageNames ?? [],
'referenceMediaPaths' => $referenceMediaPaths ?? []
]),
'outputs' => [],
'error' => $e->getMessage(),
'estimatedCostCents' => 0
]);
jsonResponse(false, null, $e->getMessage());
}
break;
}