diff --git a/config/core.extension.yml b/config/core.extension.yml index 2489fe5c5..8ca649768 100644 --- a/config/core.extension.yml +++ b/config/core.extension.yml @@ -71,6 +71,7 @@ module: path: 0 path_alias: 0 redirect: 0 + reliefweb_ai: 0 reliefweb_analytics: 0 reliefweb_api: 0 reliefweb_bookmarks: 0 diff --git a/config/reliefweb_ai.settings.yml b/config/reliefweb_ai.settings.yml new file mode 100644 index 000000000..6d460a57f --- /dev/null +++ b/config/reliefweb_ai.settings.yml @@ -0,0 +1,8 @@ +_core: + default_config_hash: fU8Wp0OCsudl5x0QSV_mUWWt2KbQuzhVwEB-o9A8peg +ocha_ai_chat: + allow_for_anonymous: false + instructions_replace: false + login_instructions: | +

The use of Ask ReliefWeb requires an account on ReliefWeb.

+

Please login or create a new account.

diff --git a/config/user.role.anonymous.yml b/config/user.role.anonymous.yml index fce92c9f5..6ec936568 100644 --- a/config/user.role.anonymous.yml +++ b/config/user.role.anonymous.yml @@ -4,6 +4,7 @@ status: true dependencies: module: - media + - ocha_ai_chat - system _core: default_config_hash: j5zLMOdJBqC0bMvSdth5UebkprJB8g_2FXHqhfpJzow @@ -13,4 +14,5 @@ weight: 0 is_admin: false permissions: - 'access content' + - 'access ocha ai chat' - 'view media' diff --git a/html/modules/custom/reliefweb_ai/README.md b/html/modules/custom/reliefweb_ai/README.md new file mode 100644 index 000000000..bc84b3483 --- /dev/null +++ b/html/modules/custom/reliefweb_ai/README.md @@ -0,0 +1,8 @@ +ReliefWeb AI +============ + +AI usage for ReliefWeb. + +## AI chat + +Currently this module simply alters the chat form to display the chat popup to anonymous users but asking them to log in or register an account to use the chat. This behavior can be controller via the configuration of this module. diff --git a/html/modules/custom/reliefweb_ai/config/install/reliefweb_ai.settings.yml b/html/modules/custom/reliefweb_ai/config/install/reliefweb_ai.settings.yml new file mode 100644 index 000000000..acee1fe5c --- /dev/null +++ b/html/modules/custom/reliefweb_ai/config/install/reliefweb_ai.settings.yml @@ -0,0 +1,7 @@ +ocha_ai_chat: + allow_for_anonymous: false + instructions_replace: false + login_instructions: | +

The use of Ask ReliefWeb requires an account on ReliefWeb.

+

Please login or create a new account.

+ diff --git a/html/modules/custom/reliefweb_ai/config/schema/reliefweb_ai.schema.yml b/html/modules/custom/reliefweb_ai/config/schema/reliefweb_ai.schema.yml new file mode 100644 index 000000000..6bacc5ebc --- /dev/null +++ b/html/modules/custom/reliefweb_ai/config/schema/reliefweb_ai.schema.yml @@ -0,0 +1,18 @@ +reliefweb_ai.settings: + type: config_object + label: 'ReliefWeb AI settings.' + mapping: + ocha_ai_chat: + type: mapping + label: 'Settings for the OCHA AI chat.' + mapping: + allow_for_anonymous: + type: boolean + label: 'Allow anonymous user to access the chat.' + instructions_replace: + type: boolean + label: 'If TRUE, replace the chat instructions when the chat is disabled (error, anonymous access etc.), otherwise append the extra instructions.' + login_instructions: + type: text + label: 'Login or register instructions for anonymous users.' + diff --git a/html/modules/custom/reliefweb_ai/reliefweb_ai.info.yml b/html/modules/custom/reliefweb_ai/reliefweb_ai.info.yml new file mode 100644 index 000000000..6badfc6b5 --- /dev/null +++ b/html/modules/custom/reliefweb_ai/reliefweb_ai.info.yml @@ -0,0 +1,7 @@ +type: module +name: ReliefWeb AI +description: 'AI usage for ReliefWeb' +package: reliefweb +core_version_requirement: ^10 +dependencies: + - drupal:ocha_ai diff --git a/html/modules/custom/reliefweb_ai/reliefweb_ai.module b/html/modules/custom/reliefweb_ai/reliefweb_ai.module new file mode 100644 index 000000000..0518f7316 --- /dev/null +++ b/html/modules/custom/reliefweb_ai/reliefweb_ai.module @@ -0,0 +1,154 @@ +query?->get('url'); + + // Add some caching context and tags. + $form['#cache']['contexts'] = array_merge($form['#cache']['contexts'] ?? [], [ + 'user.roles', 'url.query_args', + ]); + $form['#cache']['tags'] = array_merge($form['#cache']['tags'] ?? [], [ + 'config:reliefweb_ai.settings', + ]); + + // Add a more unique class to the chat submit button. + if (isset($form['actions']['submit'])) { + $form['actions']['submit']['#attributes']['class'][] = 'ocha-ai-chat-ask'; + } + + // Message to display if the form is disabled for any reason. + $disabled = ''; + + // Check if the user is anonymous and not allowed to access the chat. + if ($current_user->isAnonymous() && !$config->get('ocha_ai_chat.allow_for_anonymous')) { + $disabled = $config->get('ocha_ai_chat.login_instructions') ?? ''; + + // Redirect to the current page if possible. + if (!empty($url)) { + $disabled = strtr($disabled, [ + '@destination' => UrlHelper::encodePath(parse_url($url, \PHP_URL_PATH)), + ]); + } + } + + if (empty($disabled)) { + // Check if we have a URL to allow the chat. + if (empty($url)) { + $instructions = t('

Something went wrong.

'); + } + // Otherwise check if the language or type of the document. + else { + $router = \Drupal::service('router.no_access_checks'); + $parameters = $router->match($url); + $node = $parameters['node'] ?? NULL; + + // Disable the form if it's not a report. + if (!isset($node) || $node->bundle() !== 'report') { + $disabled = t('

Something went wrong.

'); + } + else { + // No need to show the source when chatting with a single report. + if (isset($form['source'])) { + $form['source']['#access'] = FALSE; + } + + // Only English documents are supported due to LLM limitations. + $is_english_report = FALSE; + foreach ($node->field_language as $item) { + if ($item->target_id == 267) { + $is_english_report = TRUE; + break; + } + } + if (!$is_english_report) { + $disabled = t('

Sorry, only English reports are supported.

'); + } + + // Non supported content formats. + foreach ($node->field_content_format as $item) { + if ($item->target_id == 12) { + $disabled = t('

Sorry, maps are not supported.

'); + break; + } + elseif ($item->target_id == 12570) { + $disabled = t('

Sorry, infographics are not supported.

'); + break; + } + elseif ($item->target_id == 38974) { + $disabled = t('

Sorry, interactive reports are not supported.

'); + break; + } + } + } + } + } + + if (!empty($disabled)) { + // Check whether we are requested to replace the instructions or append the + // disabled instructions. + $replace = $config->get('ocha_ai_chat.instructions_replace') === TRUE || !isset($form['chat']['content']); + + if (!$replace) { + // We cannot just append the instructions to the current ones because of + // the text format may include sanitation that removes the target + // attributes. We indeed need to preserve those attributes in the login + // instructions so the login and register links open in parent window + // and not in the chat iframe. + // So first we format the current instructions and then append the extra + // instructions. + $text = $form['chat']['content']['#text'] ?? ''; + $format = $form['chat']['content']['#format'] ?? 'markdown_editor'; + $instructions = (string) check_markup($text, $format); + $disabled = $instructions . $disabled; + } + + // Replace the instructions with a simple markup render element. + $form['chat']['content'] = [ + '#type' => 'markup', + '#markup' => $disabled, + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Disable or hide the reset of the form. + foreach (Element::children($form['chat']) as $key) { + if ($key !== 'content') { + $form['chat'][$key]['#access'] = FALSE; + } + } + foreach (Element::children($form) as $key) { + if ($key !== 'chat') { + $form[$key]['#disabled'] = TRUE; + } + } + + $form['#cache']['max-age'] = 3600; + } +} + +/** + * Implements hook_block_view_alter(). + */ +function reliefweb_ai_block_view_alter(array &$build, BlockPluginInterface $block): void { + // Alter the chat popup block, notably to adjust the caching. + if ($block->getPluginId() === 'ocha_ai_chat_chat_popup') { + $build['#pre_render'][] = [OchaAiChatPopupBlockHandler::class, 'alterBuild']; + return; + } +} diff --git a/html/modules/custom/reliefweb_ai/reliefweb_ai.services.yml b/html/modules/custom/reliefweb_ai/reliefweb_ai.services.yml new file mode 100644 index 000000000..2d25372c2 --- /dev/null +++ b/html/modules/custom/reliefweb_ai/reliefweb_ai.services.yml @@ -0,0 +1,6 @@ +services: + reliefweb_ai.ocha_ai_chat_cache_subscriber: + class: Drupal\reliefweb_ai\EventSubscriber\OchaAiChatCacheSubscriber + arguments: ['@config.factory', '@current_user'] + tags: + - { name: event_subscriber } diff --git a/html/modules/custom/reliefweb_ai/src/EventSubscriber/OchaAiChatCacheSubscriber.php b/html/modules/custom/reliefweb_ai/src/EventSubscriber/OchaAiChatCacheSubscriber.php new file mode 100644 index 000000000..f33ae48c5 --- /dev/null +++ b/html/modules/custom/reliefweb_ai/src/EventSubscriber/OchaAiChatCacheSubscriber.php @@ -0,0 +1,73 @@ + ['onResponse', -1000], + ]; + } + + /** + * {@inheritdoc} + */ + public function onResponse(ResponseEvent $event): void { + $request = $event->getRequest(); + $route = $request->attributes->get('_route'); + + if ($route === 'ocha_ai_chat.chat_form' || $route === 'ocha_ai_chat.chat_form.popup') { + $response = $event->getResponse(); + + // Ajax response are not cacheable so only handle normal form response. + if ($response instanceof CacheableResponseInterface) { + $config = $this->configFactory->get('reliefweb_ai.settings'); + + $cache_metadata = $response->getCacheableMetadata(); + + // Vary the cache by role, url parameters and config since they control + // what is displayed to the user. + $cache_metadata->addCacheContexts(['user.roles', 'url.query_args']); + $cache_metadata->addCacheTags(['config:reliefweb_ai.settings']); + + // Cache the response for 1 hour for anonymous user when we just show + // a disabled form asking to log in or register. + if ($this->currentUser->isAnonymous() && !$config->get('ocha_ai_chat.allow_for_anonymous')) { + // Cache for 1 hour. + $cache_metadata->setCacheMaxAge(3600); + // Ensure varnish for example can cache the page. + $response->headers->set('Cache-Control', 'public, max-age=3600'); + } + } + } + } + +} diff --git a/html/modules/custom/reliefweb_ai/src/OchaAiChatPopupBlockHandler.php b/html/modules/custom/reliefweb_ai/src/OchaAiChatPopupBlockHandler.php new file mode 100644 index 000000000..04ed68071 --- /dev/null +++ b/html/modules/custom/reliefweb_ai/src/OchaAiChatPopupBlockHandler.php @@ -0,0 +1,37 @@ +query?->get('url'); - if (isset($url)) { - $router = \Drupal::service('router.no_access_checks'); - $parameters = $router->match($url); - $node = $parameters['node'] ?? NULL; - - if (isset($node) && $node instanceof Report) { - // No need to show the source when chatting with a single report. - if (isset($form['source'])) { - $form['source']['#access'] = FALSE; - } - - // Only English documents are supported due to LLM limitations. - $is_english_report = FALSE; - foreach ($node->field_language as $item) { - if ($item->target_id == 267) { - $is_english_report = TRUE; - break; - } - } - if (!$is_english_report) { - $reason = t('Sorry, only English reports are supported.'); - } - - // Non supported content formats. - foreach ($node->field_content_format as $item) { - if ($item->target_id == 12) { - $reason = t('Sorry, maps are not supported.'); - break; - } - elseif ($item->target_id == 12570) { - $reason = t('Sorry, infographics are not supported.'); - break; - } - elseif ($item->target_id == 38974) { - $reason = t('Sorry, interactive reports are not supported.'); - break; - } - } - - if (!empty($reason)) { - foreach (Element::children($form) as $key) { - $form[$key]['#access'] = FALSE; - } - $form['unsupported'] = [ - '#type' => 'inline_template', - '#template' => '

{{ reason }}

', - '#context' => [ - 'reason' => $reason, - ], - ]; - } - } - } -} - -/** - * Implements hook_block_access(). - */ -function reliefweb_entities_block_access(Block $block, $operation, AccountInterface $account) { - // Disable the AI chat on map, infographic and interactive content since they - // don't have much content that can be used by the AI. - if ($operation === 'view' && $block->getPluginId() === 'ocha_ai_chat_chat_popup') { - $router = \Drupal::service('router.no_access_checks'); - try { - $parameters = $router->matchRequest(\Drupal::request()); - } - catch (\Exception $exception) { - return AccessResult::forbidden(); - } - - $node = $parameters['node'] ?? NULL; - - if (isset($node) && $node instanceof Report) { - if (in_array($node->field_content_format->target_id, [12, 12570, 38974])) { - return AccessResult::forbidden(); - } - } - } - // No opinion. - return AccessResult::neutral(); -}