Raiju/includes/ai/raiju-map.php
2026-10-25 06:05:42 +03:00

372 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Raiju map: where things are in the dashboard.
*
* One maintained table from a function or setting to the dashboard area, the settings tab and a deep
* link. The AI assistants use it as knowledge ("where do I find the dashboard language?"), the
* server builds links from it, and the dashboard assistant's tools take their areas and tabs from it.
*
* Names are not stored here: every entry points to label keys of admin/lang/*.json, so a breadcrumb
* such as "Settings › Language › Admin language" exists in all admin languages. When you add a
* dashboard area, a settings tab or a setting people ask about, add the entry here
* (tests/raiju-map-smoke.php fails when the dashboard has an area or tab the map does not know).
*/
/** Dashboard areas: id => [label key, role ('admin'|'editor'), module key or '']. */
function raijuMapSections(): array {
return [
'home' => ['dashboard_home.title', 'editor', ''],
'content' => ['nav.pages', 'editor', ''],
'news' => ['nav.news', 'editor', 'news'],
'events' => ['nav.events', 'editor', 'events'],
'mails' => ['nav.messages', 'editor', 'messages'],
'media' => ['nav.media_library', 'admin', ''],
'icons' => ['nav.icon_manager', 'admin', 'iconManager'],
'settings' => ['nav.settings', 'editor', ''],
'backup' => ['settings.backup', 'admin', ''],
'system' => ['system.title', 'admin', ''],
];
}
/** Settings tabs: id => [label key, role, needs AI features]. */
function raijuMapSettingsTabs(): array {
return [
'branding' => ['settings.branding', 'admin', false],
'theme' => ['settings.dashboard_design', 'admin', false],
'menus' => ['settings.menus', 'admin', false],
'modules' => ['settings.modules_nav', 'admin', false],
'language' => ['settings.language', 'admin', false],
'access' => ['settings.access', 'admin', false],
'email' => ['settings.email', 'admin', false],
'privacy' => ['settings.privacy', 'admin', false],
'forms' => ['forms.title', 'admin', false],
'ai' => ['ai.settings', 'admin', true],
'users' => ['settings.users', 'admin', false],
'my-account' => ['settings.my_account', 'editor', false],
'login' => ['settings.login', 'admin', false],
'danger' => ['settings.danger_zone', 'admin', false],
];
}
/**
* Things people look for. id => [section, settings tab or '', label key, English synonyms, English hint].
* The label key is the name in the dashboard (so it exists in every admin language); the synonyms
* are words people use that the dashboard does not (they are matched in any language).
*/
function raijuMapEntries(): array {
return [
// Areas
'area_home' => ['home', '', 'dashboard_home.title', ['overview', 'start', 'statistics', 'visitors', 'startseite des dashboards', 'übersicht', 'besucher'], 'Overview with visitor statistics, status and shortcuts.'],
'area_pages' => ['content', '', 'nav.pages', ['pages', 'page list', 'new page', 'create page', 'delete page', 'duplicate page', 'seiten', 'neue seite', 'seite löschen', 'seite anlegen', 'seite duplizieren', 'unterseite'], 'All pages: create, rename, duplicate, delete, open the editor, page history and backups.'],
'area_news' => ['news', '', 'nav.news', ['news', 'blog', 'posts', 'articles', 'neuigkeiten', 'beiträge', 'artikel', 'aktuelles'], 'News posts: create, edit, hide, publish.'],
'area_events' => ['events', '', 'nav.events', ['events', 'calendar', 'dates', 'termine', 'veranstaltungen', 'kalender'], 'Events with date, time and place.'],
'area_messages' => ['mails', '', 'nav.messages', ['messages', 'inbox', 'form submissions', 'contact requests', 'nachrichten', 'anfragen', 'kontaktanfragen', 'formulareinträge'], 'Messages sent through the website forms.'],
'area_media' => ['media', '', 'nav.media_library', ['media', 'images', 'pictures', 'files', 'uploads', 'upload', 'bilder', 'dateien', 'medien', 'hochladen', 'fotos'], 'Media library: upload, rename, delete and reuse images and files.'],
'area_icons' => ['icons', '', 'nav.icon_manager', ['icons', 'symbols', 'symbole'], 'Icons used on the website.'],
'area_settings' => ['settings', '', 'nav.settings', ['settings', 'preferences', 'configuration', 'einstellungen', 'konfiguration'], 'All site and account settings, grouped in tabs.'],
'area_backup' => ['backup', '', 'settings.backup', ['backup', 'backups', 'restore', 'download site', 'save copy', 'sicherung', 'wiederherstellen', 'wiederherstellung', 'datensicherung'], 'Create, download, schedule and restore site backups.'],
'area_system' => ['system', '', 'system.title', ['system', 'status', 'health', 'security check', 'updates', 'version', 'systemstatus', 'sicherheitsprüfung', 'sicherheitscheck'], 'System status, security check and update notes.'],
// Settings tabs
'tab_branding' => ['settings', 'branding', 'settings.branding', ['branding', 'brand', 'name of the site', 'firmenname', 'marke'], 'Site name, favicon, logo and branding visibility.'],
'tab_theme' => ['settings', 'theme', 'settings.dashboard_design', ['theme', 'design', 'colours', 'colors', 'appearance', 'farben', 'erscheinungsbild', 'darstellung'], 'Look of the dashboard: light/dark/system, primary and accent colour.'],
'tab_menus' => ['settings', 'menus', 'settings.menus', ['menu', 'navigation', 'main menu', 'footer menu', 'nav', 'menü', 'hauptmenü', 'fußmenü', 'navigation der website'], 'Which pages show in the main and footer menus.'],
'tab_modules' => ['settings', 'modules', 'settings.modules_nav', ['modules', 'features', 'turn on news', 'turn off ai', 'module', 'funktionen ein- ausschalten', 'ki ausschalten', 'news ausschalten'], 'Switch dashboard modules (news, events, messages, icons, AI) on or off.'],
'tab_language' => ['settings', 'language', 'settings.language', ['languages', 'sprache', 'sprachen', 'language settings'], 'Dashboard language and the language of AI image labels.'],
'tab_access' => ['settings', 'access', 'settings.access', ['maintenance', 'offline', 'coming soon', 'launch', 'preview', 'under construction', 'wartung', 'baustelle', 'vorschau', 'zugriff'], 'Maintenance mode, countdown and preview bypass link.'],
'tab_email' => ['settings', 'email', 'settings.email', ['email', 'e-mail', 'mail', 'sender', 'notification', 'contact form mail', 'mailversand', 'absender', 'benachrichtigung', 'empfänger'], 'Mail sending (SMTP), recipient and sender for form messages.'],
'tab_privacy' => ['settings', 'privacy', 'settings.privacy', ['privacy', 'cookies', 'cookie', 'datenschutz', 'e-mail verschleierung'], 'Privacy options, visitor statistics and e-mail obfuscation.'],
'tab_forms' => ['settings', 'forms', 'forms.title', ['forms', 'form', 'contact form', 'formulare', 'formular', 'kontaktformular'], 'Public forms and their fields.'],
'tab_ai' => ['settings', 'ai', 'ai.settings', ['ai', 'artificial intelligence', 'api key', 'model', 'provider', 'openai', 'anthropic', 'agent', 'ki', 'ki-einstellungen', 'api-schlüssel', 'modell', 'kosten', 'limit'], 'AI provider, API key, models, limits, costs and the AI agent.'],
'tab_users' => ['settings', 'users', 'settings.users', ['users', 'user', 'accounts', 'editors', 'roles', 'add user', 'delete user', 'benutzer', 'nutzer', 'konten', 'rollen', 'benutzer anlegen', 'benutzer löschen'], 'Create, edit and remove users and set their role (administrator or editor).'],
'tab_my_account' => ['settings', 'my-account', 'settings.my_account', ['my account', 'profile', 'my email', 'mein konto', 'profil'], 'Your own password and e-mail address.'],
'tab_login' => ['settings', 'login', 'settings.login', ['login', 'sign in', 'log in page', 'frontend login', 'anmeldung', 'login-seite', 'anmeldeseite'], 'Design of the login page and where people land after signing in.'],
'tab_danger' => ['settings', 'danger', 'settings.danger_zone', ['reset', 'delete everything', 'factory reset', 'zurücksetzen', 'alles löschen', 'werkseinstellungen'], 'Total reset of the site. Irreversible; only the person who owns the site should use it.'],
// Single settings
'set_admin_language' => ['settings', 'language', 'settings.admin_language', ['system language', 'interface language', 'ui language', 'dashboard language', 'admin language', 'language of the dashboard', 'change language', 'systemsprache', 'oberflächensprache', 'sprache des dashboards', 'dashboard-sprache', 'sprache der oberfläche', 'sprache ändern', 'idioma del sistema', 'langue du système', 'lingua del sistema'], 'Language of the dashboard (the admin interface). The assistant can change it after you confirm.'],
'set_theme' => ['settings', 'theme', 'copilot.dash_setting_adminTheme', ['dark mode', 'light mode', 'dark', 'light', 'night mode', 'dashboard theme', 'dunkelmodus', 'hellmodus', 'dunkel', 'hell'], 'Light, dark or follow the device. The assistant can change it after you confirm.'],
'set_colors' => ['settings', 'theme', 'copilot.dash_setting_primaryColor', ['primary colour', 'primary color', 'accent colour', 'accent color', 'dashboard colour', 'hauptfarbe', 'akzentfarbe', 'farbe des dashboards'], 'Primary and accent colour of the dashboard. The assistant can change them after you confirm.'],
'set_site_name' => ['settings', 'branding', 'settings.site_name', ['site name', 'website name', 'seitenname', 'name der website'], 'Name of the website.'],
'set_logo' => ['settings', 'branding', 'settings.logo', ['logo', 'brand logo'], 'Logo of the website.'],
'set_favicon' => ['settings', 'branding', 'settings.favicon', ['favicon', 'browser icon', 'tab icon'], 'Small icon in the browser tab.'],
'set_maintenance' => ['settings', 'access', 'settings.maintenance', ['maintenance mode', 'take site offline', 'coming soon page', 'wartungsmodus', 'seite offline nehmen'], 'Show visitors a maintenance page instead of the site.'],
'set_smtp' => ['settings', 'email', 'settings.smtp_settings', ['smtp', 'mail server', 'mailserver', 'send emails', 'mails senden'], 'Mail server for sending form messages.'],
'set_password' => ['settings', 'my-account', 'settings.change_password', ['password', 'change my password', 'passwort ändern', 'mein passwort'], 'Change your own password.'],
'set_analytics' => ['settings', 'privacy', 'analytics.enabled', ['visitor statistics', 'analytics', 'tracking', 'besucherstatistik', 'statistik'], 'Switch visitor statistics on or off.'],
'set_total_reset' => ['settings', 'danger', 'settings.total_reset', ['reset the whole site', 'wipe site', 'alles zurücksetzen'], 'Wipes the site back to a fresh install. Never done by the assistant.'],
'set_ai_label' => ['settings', 'language', 'settings.ai_label_artwork', ['ai label', 'ai images label', 'ki-kennzeichnung', 'kennzeichnung ki-bilder'], 'Language of the AI label shown on generated images.'],
];
}
/** Ids of the entries that have a dashboard change action (the assistant can do them after a confirm). */
function raijuMapActionEntries(): array {
return ['adminLanguage' => 'set_admin_language', 'adminTheme' => 'set_theme', 'primaryColor' => 'set_colors', 'accentColor' => 'set_colors'];
}
// ---------------------------------------------------------------------------
// Names and links
// ---------------------------------------------------------------------------
/** Admin UI languages that have a language file (code => strings), loaded once. */
function raijuMapLanguageStrings(string $code): array {
static $cache = [];
if (!preg_match('/^[a-z]{2,3}$/', $code)) return [];
if (!isset($cache[$code])) {
$file = __DIR__ . '/../../admin/lang/' . $code . '.json';
$data = is_file($file) ? json_decode((string)file_get_contents($file), true) : [];
$cache[$code] = is_array($data) ? $data : [];
}
return $cache[$code];
}
/** All admin language codes. */
function raijuMapLanguageCodes(): array {
static $codes = null;
if ($codes === null) {
$codes = [];
foreach (glob(__DIR__ . '/../../admin/lang/*.json') ?: [] as $file) {
$name = basename($file, '.json');
if (!str_starts_with($name, 'editor-')) $codes[] = $name;
}
sort($codes);
}
return $codes;
}
/** Text of a label key in an admin language (English, then the key, as fallback). */
function raijuMapLabel(string $key, ?string $language = null): string {
if ($language === null) return function_exists('t') ? t($key) : (raijuMapLanguageStrings('en')[$key] ?? $key);
return raijuMapLanguageStrings($language)[$key] ?? raijuMapLanguageStrings('en')[$key] ?? $key;
}
function raijuMapIsAdmin(): bool {
return function_exists('isAdmin') ? isAdmin() : (($_SESSION['admin_role'] ?? '') === 'admin');
}
/** Whether a dashboard module is switched on (the same default as the dashboard: on). */
function raijuMapModuleEnabled(string $module): bool {
if ($module === '') return true;
static $cache = ['key' => null, 'modules' => []];
$path = defined('SETTINGS_PATH') ? (string)SETTINGS_PATH : '';
$key = $path . '|' . ($path !== '' && is_file($path) ? filemtime($path) . '|' . filesize($path) : '');
if ($cache['key'] !== $key) { // read again when the settings file changed (the file is small and read at most once per change)
$modules = [];
if ($path !== '' && is_file($path)) {
$settings = json_decode((string)file_get_contents($path), true);
if (is_array($settings) && is_array($settings['modules'] ?? null)) $modules = $settings['modules'];
}
$cache = ['key' => $key, 'modules' => $modules];
}
return !array_key_exists($module, $cache['modules']) || !empty($cache['modules'][$module]);
}
/** Whether the AI tab exists in the settings (the AI module is on). */
function raijuMapAiTabAvailable(): bool {
return raijuMapModuleEnabled('ai');
}
/** Address of the dashboard, from the script that serves this request (same origin, no host). */
function raijuMapDashboardUrl(): string {
$script = (string)($_SERVER['SCRIPT_NAME'] ?? '');
if (!preg_match('#/\.{1,2}(?:/|$)#', $script) && preg_match('#^((?:/[A-Za-z0-9._~-]+)*)/admin/[A-Za-z0-9._/~-]*\.php$#', $script, $match)) {
return $match[1] . '/admin/dashboard.php';
}
return '/admin/dashboard.php';
}
/** "This module is switched off. You can switch it on under Settings › Modules." in the dashboard language. */
function raijuMapModuleOffNote(): string {
$path = raijuMapLabel('nav.settings') . ' › ' . raijuMapLabel('settings.modules_nav');
return str_replace('{path}', $path, raijuMapLabel('copilot.map_module_off'));
}
/** The part after # that opens an area or a settings tab. */
function raijuMapHash(string $section, string $tab = ''): string {
$public = ['home' => 'dashboard', 'content' => 'pages', 'mails' => 'messages'][$section] ?? $section;
return '#' . $public . ($section === 'settings' && $tab !== '' ? '/' . $tab : '');
}
/**
* Describe one entry for a person: name, breadcrumb and link.
*
* @return array|null null for an unknown id
* [id, title, path, url, section, settingsTab, available, restricted, note]
* restricted: the signed-in role may not use it (no link); available: false when a module is switched off
* (the link then leads to the modules tab).
*/
function raijuMapDescribe(string $id, ?bool $isAdmin = null): ?array {
$entries = raijuMapEntries();
if (!isset($entries[$id])) return null;
[$section, $tab, $labelKey] = $entries[$id];
$isAdmin ??= raijuMapIsAdmin();
$sections = raijuMapSections();
$tabs = raijuMapSettingsTabs();
$crumbs = [raijuMapLabel($sections[$section][0])];
if ($tab !== '') $crumbs[] = raijuMapLabel($tabs[$tab][0]);
$title = raijuMapLabel($labelKey);
if (end($crumbs) !== $title) $crumbs[] = $title;
$role = $tab !== '' ? $tabs[$tab][1] : $sections[$section][1];
if ($sections[$section][1] === 'admin') $role = 'admin';
$restricted = $role === 'admin' && !$isAdmin;
$available = true;
$note = '';
$linkSection = $section;
$linkTab = $tab;
if ($restricted) {
$note = raijuMapLabel('copilot.map_admin_only');
} elseif (!raijuMapModuleEnabled($sections[$section][2])) {
$available = false;
$note = raijuMapModuleOffNote();
$linkSection = 'settings';
$linkTab = 'modules';
} elseif ($tab === 'ai' && !raijuMapAiTabAvailable()) {
$available = false;
$note = raijuMapModuleOffNote();
$linkSection = 'settings';
$linkTab = 'modules';
}
return [
'id' => $id, 'title' => $title, 'path' => implode(' › ', $crumbs),
'url' => $restricted ? '' : raijuMapDashboardUrl() . raijuMapHash($linkSection, $linkTab),
'section' => $section, 'settingsTab' => $tab, 'available' => $available, 'restricted' => $restricted, 'note' => $note,
];
}
/** The entry that belongs to a change_setting key, as a description (for the cards). */
function raijuMapDescribeSetting(string $setting, ?bool $isAdmin = null): ?array {
$id = raijuMapActionEntries()[$setting] ?? null;
return $id === null ? null : raijuMapDescribe($id, $isAdmin);
}
// ---------------------------------------------------------------------------
// Finding
// ---------------------------------------------------------------------------
/** Lowercase text without accents and with simple word characters (for matching across languages). */
function raijuMapNormalize(string $text): string {
$text = mb_strtolower($text, 'UTF-8');
$text = strtr($text, [
'ä' => 'a', 'ö' => 'o', 'ü' => 'u', 'ß' => 'ss', 'á' => 'a', 'à' => 'a', 'â' => 'a', 'ã' => 'a', 'å' => 'a', 'ą' => 'a',
'ç' => 'c', 'ć' => 'c', 'č' => 'c', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', 'ę' => 'e', 'ě' => 'e', 'í' => 'i',
'ì' => 'i', 'î' => 'i', 'ï' => 'i', 'ı' => 'i', 'İ' => 'i', 'ł' => 'l', 'ń' => 'n', 'ñ' => 'n', 'ň' => 'n', 'ó' => 'o',
'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ś' => 's', 'š' => 's', 'ş' => 's', 'ř' => 'r', 'ť' => 't', 'ú' => 'u',
'ù' => 'u', 'û' => 'u', 'ů' => 'u', 'ü' => 'u', 'ý' => 'y', 'ź' => 'z', 'ż' => 'z', 'ž' => 'z', 'ğ' => 'g',
]);
return trim((string)preg_replace('/[^a-z0-9]+/', ' ', $text));
}
/** Search terms of an entry: its name in every admin language, plus the synonyms (normalised, unique). */
function raijuMapTerms(string $id): array {
static $cache = [];
if (isset($cache[$id])) return $cache[$id];
$entry = raijuMapEntries()[$id];
$terms = [];
foreach (raijuMapLanguageCodes() as $code) $terms[] = raijuMapNormalize(raijuMapLabel($entry[2], $code));
foreach ($entry[3] as $alias) $terms[] = raijuMapNormalize($alias);
return $cache[$id] = array_values(array_unique(array_filter($terms, static fn($term) => strlen($term) >= 2)));
}
/**
* Find entries for a free-text question or request, best first.
*
* @return array<int,array> descriptions (see raijuMapDescribe) with 'score'
*/
function raijuMapLookup(string $query, ?bool $isAdmin = null, int $limit = 3): array {
$query = raijuMapNormalize(mb_substr($query, 0, 600, 'UTF-8'));
if ($query === '') return [];
$padded = ' ' . $query . ' ';
$tokens = array_values(array_filter(explode(' ', $query), static fn($token) => strlen($token) >= 4));
$scored = [];
foreach (array_keys(raijuMapEntries()) as $id) {
$score = 0;
foreach (raijuMapTerms($id) as $term) {
if (str_contains($padded, ' ' . $term . ' ')) {
$score = max($score, 100 + strlen($term) * 4); // the whole name or synonym is in the text
continue;
}
foreach ($tokens as $token) {
if (strlen($term) >= 5 && str_contains($token, $term)) $score = max($score, 30 + strlen($term)); // "systemsprache" contains "sprache"
elseif (strlen($token) >= 5 && !str_contains($term, ' ') && str_contains($term, $token)) $score = max($score, 20 + strlen($token));
}
}
if ($score > 0) $scored[$id] = $score;
}
arsort($scored);
$best = $scored ? max($scored) : 0;
$result = [];
foreach ($scored as $id => $score) {
if ($score < 40 || $score < $best - 25) continue;
$description = raijuMapDescribe((string)$id, $isAdmin);
if ($description === null) continue;
$result[] = $description + ['score' => $score];
if (count($result) >= $limit) break;
}
return $result;
}
// ---------------------------------------------------------------------------
// Knowledge for the models
// ---------------------------------------------------------------------------
/**
* Compact description of the dashboard for the system prompt. Names are in the dashboard language,
* hints in English (they are for the model). Entries the role cannot use are left out for editors.
*/
function raijuMapKnowledge(bool $isAdmin): string {
$lines = ['Dashboard map (name = what the person sees in the dashboard; tell people where to find things with these exact names and offer the link):'];
foreach (array_keys(raijuMapEntries()) as $id) {
$description = raijuMapDescribe($id, $isAdmin);
if ($description === null || $description['restricted']) continue;
$entry = raijuMapEntries()[$id];
$lines[] = '- ' . $id . ': ' . $description['path'] . ' — ' . $entry[4] . ($description['available'] ? '' : ' (switched off: ' . $description['note'] . ')');
}
if (!$isAdmin) $lines[] = 'This person is an editor: settings other than My account, backups, media library, users and system belong to administrators.';
$lines[] = 'The site languages (primary and secondary) and the dashboard language are different things: the dashboard language is set in Settings › Language; the site languages are set when the site is installed.';
return implode("\n", $lines);
}
/** Entry ids a model may name in tools (those the role can reach). */
function raijuMapToolIds(bool $isAdmin): array {
$ids = [];
foreach (array_keys(raijuMapEntries()) as $id) {
$description = raijuMapDescribe($id, $isAdmin);
if ($description !== null && !$description['restricted']) $ids[] = $id;
}
return $ids;
}
/** One short paragraph about the structure of the dashboard, for the Copilot's knowledge base (names in the dashboard language). */
function raijuMapStructureSummary(bool $isAdmin): string {
$areas = [];
foreach (raijuMapSections() as $id => [$label, $role, $module]) {
if ($role === 'admin' && !$isAdmin) continue;
if (!raijuMapModuleEnabled($module)) continue;
$areas[] = raijuMapLabel($label);
}
$tabs = [];
foreach (raijuMapSettingsTabs() as $id => [$label, $role, $needsAi]) {
if ($id === 'danger' || ($role === 'admin' && !$isAdmin) || ($needsAi && !raijuMapAiTabAvailable())) continue;
$tabs[] = raijuMapLabel($label);
}
return 'The dashboard has these areas: ' . implode(', ', $areas) . '. The settings have these tabs: ' . implode(', ', $tabs)
. '. The dashboard language is set in ' . raijuMapLabel('nav.settings') . ' › ' . raijuMapLabel('settings.language') . ', not in the page editor. Tell people where something is with these names; never only say "in the settings".';
}
/** Pages of the site (content key and title), for the dashboard assistant. */
function raijuMapPagesKnowledge(string $contentRoot, int $limit = 40): string {
$pages = [];
foreach (glob(rtrim($contentRoot, '/') . '/pages/*.json') ?: [] as $file) {
$key = basename($file, '.json');
if (in_array($key, ['footer', 'sidebar'], true) || !preg_match('/^[a-z]{2}_[a-z0-9_\/-]+$/i', $key)) continue;
$json = json_decode((string)file_get_contents($file), true);
$title = is_array($json) ? trim(strip_tags((string)($json['title'] ?? ''))) : '';
$pages[] = $key . ($title !== '' ? ' — ' . mb_substr($title, 0, 50, 'UTF-8') : '');
}
sort($pages);
$total = count($pages);
if (!$total) return '';
return 'Pages of this site (' . $total . '): ' . implode('; ', array_slice($pages, 0, $limit)) . ($total > $limit ? '; …' : '') . '. Each page opens in the page editor from the Pages area.';
}