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