\n";
@@ -226,7 +301,7 @@ private function formattedResponse(): string {
$results .= "
\n";
$results .= "
RESULTS
\n";
- foreach($data["searchResults"] as $key => $result) {
+ foreach($this->sc_response["search_results"] as $key => $result) {
$res_id = $key + 1;
$ans = '';
$snip = "";
@@ -262,23 +337,241 @@ private function formattedResponse(): string {
}
/**
- * Update the $this->response["conversation"]["ratings"] array if the safety scores
- * in $ratings are higher (less safe) than those already stored.
+ * Return the processed results in a standardized array.
+ * @return array
+ */
+ public function getResults(): array {
+ return $this->sc_response;
+ }
+
+ /**
+ * Establish the safety scores and retuurn.
+ * Only save safety scores in $ratings are higher (less safe) than those
+ * already stored.
*
- * @param array $ratings The safetyRatings from a gemini ::predict call.
+ * @param array $ratings The safetyRatings from vertex.
*
- * @return void
+ * @return array
*/
- private function loadSafetyRatings(array $ratings): void {
+ private function loadSafetyRatings(array $ratings): array {
+
+ $output = [];
- if (!isset($this->response["body"]["safetyRatings"])) {
- $this->response["body"]["safetyRatings"] = [];
+ foreach(($ratings["categories"] ?? []) as $key => $rating) {
+ $output[$rating] = $ratings["scores"][$key];
}
- foreach($ratings["categories"] as $key => $rating) {
- $this->response["body"]["safetyRatings"][$rating] = $ratings["scores"][$key];
+ return $output;
+
+ }
+
+ /**
+ * Load Search Results into a simple, standardized search output format.
+ * Also de-duplicates the results based on the ultimate node which is
+ * referenced in the result link.
+ *
+ * The array returned is a clone of the array in aiSearchResult (bos_search),
+ * but we have copied so as not to create a dependedncy between these modules
+ * at this point.
+ *
+ * @param array $results Output from AI Model
+ *
+ * @return array Standardized & simplified array of search results.
+ */
+ private function loadSearchResults(array $results): array {
+ $output = [];
+
+ if (empty($results)) {
+ return [];
}
+ $alias_manager = \Drupal::service('path_alias.manager');
+ $redirect_manager = \Drupal::service('redirect.repository');
+
+ $citations = $this->sc_response["citations"] ?: [];
+
+ foreach($results as $result) {
+
+ // Check if this result is already showing in the citations.h
+ $is_citation = FALSE;
+ if (!empty($citations)) {
+ foreach ($citations as $key => $citation) {
+ if ($citation["id"] == $result["id"]) {
+ // Mark results as being in the citations set
+ $is_citation = TRUE;
+ // Mark citation as being in results set.
+ $this->sc_response["citations"][$key]["is_result"] = TRUE;
+ break;
+ }
+ }
+ }
+
+ /** Standardizes search result - output array is a clone of class aiSearchResult. */
+
+ $path_alias = explode(".gov",$result["document"]["derivedStructData"]["link"],2)[1];
+ if (!empty($path_alias)) {
+
+ // Strip out the alias from any other querystings etc
+ $path_alias = explode('?', $path_alias, 2);
+ $path_alias = explode('#', $path_alias[0], 2)[0];
+
+ // get the nid for this page alias (to prevent duplicates)
+ $path = $alias_manager->getPathByAlias($path_alias);
+ $path_parts = explode('/', $path);
+ $nid = array_pop($path_parts);
+
+ if (!is_numeric($nid)) {
+ // If we can't get the node ID then it is possibly a redirect to
+ // another page, so try to track that down...
+
+ $redirects = $redirect_manager->findBySourcePath(trim($path_alias, "/"));
+ if (!empty($redirects)) {
+ $redirect = reset($redirects);
+ $original_alias = explode(":", $redirect->getRedirect()['uri'], 2)[1] ?? $redirect->getRedirect()['uri'];
+ $path = $alias_manager->getPathByAlias($original_alias);
+ $path_parts = explode('/', $path);
+ $nid = array_pop($path_parts);
+ }
+ }
+
+ if (!is_numeric($nid)) {
+ // Well ... interesting.
+ // Set the nid equal to the original node path so at least we
+ // de-duplicate.
+ $nid = $path;
+ }
+
+ }
+
+ $node = \Drupal::entityTypeManager()->getStorage('node')->load($nid);
+ $description = "";
+ if ($node && $node->hasField("field_intro_text")) {
+ $description = $node->get("field_intro_text")->value;
+ }
+ if ($node && $node->hasField("body")) {
+ $description .= $node->get("body")->summary ?: $node->get("body")->value;
+ }
+ if (empty($description) && $node && $node->hasField("field_need_to_know")) {
+ $description = $node->get("field_need_to_know")->value;
+ }
+
+ $title = explode("|", $result['document']['derivedStructData']['title'], 2)[0];
+ $output[$result['id']] = [
+ "content" => $result['document']['derivedStructData']['extractive_answers'][0]['content'],
+ "description" => trim(strip_tags($description)),
+ "id" => $result['id'],
+ "is_citation" => $is_citation,
+ "link" => $result['document']['derivedStructData']['link'],
+ "link_title" => $result['document']['derivedStructData']['displayLink'],
+ "ref" => $result['document']['name'],
+ "snippet" => $result['document']['derivedStructData']['snippets'][0]['snippet'] ?: "",
+ "title" => trim($title),
+ ];
+
+
+ }
+ return array_values($output);
+ }
+
+ /**
+ * Load Vertex available metadata into array and return.
+ *
+ * @param array $metadata
+ *
+ * @return array
+ */
+ private function loadMetadata(array $metadata) {
+ $map = [
+ "session_id" => "Drupal Internal",
+ ];
+ $exclude_meta = [
+ "conversation",
+ "session_id"
+ ];
+ foreach($metadata as $key => $value) {
+ $node = $map[$key] ?? "Request";
+ if (!in_array($key, $exclude_meta)) {
+ $output[$node][ucwords(str_replace("_", " ", $key))] = [
+ "key" => $key,
+ "value" => $value,
+ ];
+ }
+ }
+ $output[$node]["Full Prompt"] = [
+ "key" => "Full Prompt",
+ "value" => $this->request["body"]["summarySpec"]["modelPromptSpec"]["preamble"],
+ ];
+ foreach($this->settings[$this->id()] as $key => $value) {
+ $node = $map[$key] ?? "Model Config";
+ $output[$node][ucwords(str_replace("_", " ", $key))] = [
+ "key" => $key,
+ "value" => $value
+ ];
+ }
+ $output["Model State"]["Current Conversation Length"] = ["key" => "conversation_length", "value" => count($this->response["body"]["conversation"]["messages"]) / 2];
+ $output["Model Response"]["Endpoint"] = ["key" => "conversation_endpoint", "value" => $this->request["protocol"] . "//" . $this->request["host"] . '/' . $this->request["endpoint"]];
+ $output["Model Response"]["Conversation"] = ["key" => "conversation_name", "value" => $this->response["body"]["conversation"]["name"]];
+ $output["Model Response"]["State"] = ["key" => "conversation_state", "value" => $this->response["body"]["conversation"]["state"]];
+ $output["Model Response"]["PseudoId"] = ["key" => "conversation_ref", "value" => $this->response["body"]["conversation"]["userPseudoId"]];
+ $output["Model Response"]["Drupal Internal Id"] = ["key" => "session_id", "value" => $metadata["session_id"] ?? ""];
+ $output["Model Response"]["Query Duration"] = ["key" => "conversation_query_duration", "value" => $this->response["elapsedTime"]];
+ $output["Model Response"]["Search Results Returned"] = ["key" => "results_length", "value" => count($this->response["body"]["searchResults"] ?? [])];
+ $output["Model Response"]["Citations Returned"] = ["key" => "citations_length", "value" => count($this->response["body"]["reply"]["summary"]["summaryWithMetadata"]["citationMetadata"]["citations"] ?? [])];
+ return $output;
+ }
+
+ /**
+ * Creates a unified citation array from a list of citations and references.
+ *
+ * @param array $citations Citations from Vertex
+ * @param array $references References from Vertex
+ *
+ * @return array a unified array of citations with their references.
+ */
+ private function loadCitations(array $citations, array $references, string &$body): array {
+ $output = [];
+
+ foreach ($references as $key => $reference) {
+ $output[$key] = $reference;
+ $output[$key]["title"] = trim(explode("|", $output[$key]["title"], 2)[0]);
+ $output[$key]["ref"] = $output[$key]["document"];
+ $ref = explode("/", $output[$key]["document"]);
+ $output[$key]["id"] = array_pop($ref);
+ $output[$key]["locations"] = [];
+ // $output[$key]["original_key"] = $key;
+
+ foreach ($citations as $citation) {
+ foreach ($citation["sources"] as $source) {
+ if (($source["referenceIndex"] ?? 0) == $key) {
+ $output[$key]["locations"][] = [
+ "startIndex" => $citation["startIndex"] ?? 0,
+ "endIndex" => $citation["endIndex"] ?? strlen($body),
+ ];
+ }
+ }
+ }
+
+ unset($output[$key]["document"]);
+ }
+
+ // reindex the output array, keep the original key to match the citation #'s
+ // and replace text on the page
+ $out = [];
+ $new_key = 1;
+ foreach ($output as $key => $value) {
+ if (!empty($value["locations"])) {
+ $value["original_key"] = $key + 1;
+ $body = preg_replace("~\[" . $value["original_key"] . "\]~", "[" . $new_key . "]", $body);
+ $body = preg_replace("~, " . $value["original_key"] . "~", "[" . $new_key . "]", $body);
+ $body = preg_replace("~" . $value["original_key"] . " ,~", "[" . $new_key . "]", $body);
+ $out[$new_key++] = $value;
+ }
+ }
+
+ // Make the index numbers sequential, starting at 1
+
+ // Todo: add links into the body ?
+ return $out;
}
/**
@@ -288,8 +581,10 @@ public function buildForm(array &$form): void {
$project_id="612042612588";
$model_id="drupalwebsite_1702919119768";
+ $engine_id="oeoi-search-pilot_1726266124376";
$location_id="global";
$endpoint="https://discoveryengine.googleapis.com";
+ $model="stable";
$settings = $this->settings['conversation'] ?? [];
@@ -326,6 +621,16 @@ public function buildForm(array &$form): void {
"placeholder" => 'e.g. ' . $model_id,
],
],
+ 'engine_id' => [
+ '#type' => 'textfield',
+ '#title' => t('Engine'),
+ '#description' => t(''),
+ '#default_value' => $settings['engine_id'] ?? $engine_id,
+ '#required' => TRUE,
+ '#attributes' => [
+ "placeholder" => 'e.g. ' . $engine_id,
+ ],
+ ],
'location_id' => [
'#type' => 'textfield',
'#title' => t('Location (always global for now)'),
@@ -347,6 +652,17 @@ public function buildForm(array &$form): void {
"placeholder" => 'e.g. ' . $endpoint,
],
],
+ 'model' => [
+ '#type' => 'select',
+ '#title' => t('The LLM model to use'),
+ '#description' => t('This is the model that will be used.
Best to set to "stable" for latest stable release (which typically is frozen and only updated periodically) or "preview" for the latest model (which is more experimental and can be updated more frequently).
See https://cloud.google.com/generative-ai-app-builder/docs/answer-generation-models#models'),
+ '#default_value' => $settings['model'] ?? $model,
+ '#options' => [
+ 'stable' => 'Stable',
+ 'preview' => 'Preview',
+ ],
+ '#required' => TRUE,
+ ],
'service_account' => [
'#type' => 'select',
'#title' => t('The default service account to use'),
@@ -400,15 +716,19 @@ public function submitForm(array $form, FormStateInterface $form_state): void {
if ($config->get("conversation.project_id") !== $values['project_id']
||$config->get("conversation.datastore_id") !== $values['datastore_id']
+ ||$config->get("conversation.engine_id") !== $values['engine_id']
||$config->get("conversation.location_id") !== $values['location_id']
||$config->get("conversation.service_account") !== $values['service_account']
||$config->get("conversation.allow_conversation") !== $values['allow_conversation']
+ ||$config->get("conversation.model") !== $values['model']
||$config->get("conversation.endpoint") !== $values['endpoint']) {
$config->set("conversation.project_id", $values['project_id'])
->set("conversation.datastore_id", $values['datastore_id'])
+ ->set("conversation.engine_id", $values['engine_id'])
->set("conversation.location_id", $values['location_id'])
->set("conversation.allow_conversation", $values['allow_conversation'])
->set("conversation.endpoint", $values['endpoint'])
+ ->set("conversation.model", $values['model'])
->set("conversation.service_account", $values['service_account'])
->save();
}
@@ -422,7 +742,6 @@ public function validateForm(array $form, FormStateInterface &$form_state): void
// not required
}
-
/**
* Ajax callback to test Conversation Service.
*
@@ -454,4 +773,158 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st
}
+ /**
+ * @inheritDoc
+ */
+ public function hasFollowup(): bool {
+ return $this->config->get("conversation.allow_conversation");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSettings(): array {
+ return $this->settings[$this->id()];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availablePrompts(): array {
+ return GcGenerationPrompt::getPrompts($this->id());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availableDataStores(?string $service_account, ?string $project_id): array {
+
+ $settings = $this->settings[$this->id()];
+
+ if (!empty($service_account) && $service_account != "default") {
+ $settings['service_account'] = $service_account;
+ }
+ if (!empty($project_id) && $project_id != "default") {
+ $settings['project_id'] = $project_id;
+ }
+
+ // Get token.
+ try {
+ $headers = [
+ "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer")
+ ];
+ }
+ catch (Exception $e) {
+ $this->error = $e->getMessage() ?? "Error getting access token.";
+ return [];
+ }
+
+ $url = GcGenerationURL::build(GcGenerationURL::DATASTORE, $settings);
+
+ // Query the AI.
+ try {
+ $results = $this->get($url, NULL, $headers);
+ }
+ catch(\Exception $e) {
+ return [];
+ }
+
+ $output = [];
+ foreach($results["dataStores"] ?? [] as $dataStore) {
+ $dataStoreName = explode("/", $dataStore["name"]);
+ $dataStoreId = array_pop($dataStoreName);
+ $output[$dataStoreId] = $dataStore['displayName'];
+ }
+ return $output;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availableEngines(?string $service_account, ?string $project_id): array {
+ // Get token.
+ $settings = $this->settings[$this->id()];
+
+ if (!empty($service_account) && $service_account != "default") {
+ $settings['service_account'] = $service_account;
+ }
+ if (!empty($project_id) && $project_id != "default") {
+ $settings['project_id'] = $project_id;
+ }
+
+ try {
+ $headers = [
+ "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer")
+ ];
+ }
+ catch (Exception $e) {
+ $this->error = $e->getMessage() ?? "Error getting access token.";
+ return [];
+ }
+
+ $url = GcGenerationURL::build(GcGenerationURL::ENGINE, $settings);
+
+ // Query the AI.
+ $output = [];
+ try {
+ $results = $this->get($url, NULL, $headers);
+ }
+ catch(\Exception $e) {}
+
+ foreach($results["engines"] ?: [] as $engine) {
+ $engineName = explode("/", $engine["name"]);
+ $engineId = array_pop($engineName);
+ $output[$engineId] = $engine['displayName'];
+ }
+
+ return $output;
+
+ }
+
+ public function availableProjects(?string $service_account): array {
+
+ if (!empty($service_account) && $service_account != "default") {
+ $settings['service_account'] = $service_account;
+ }
+
+ // TODO: For this to work the service account needs resourcemanager.projects.list
+ // permission on the organization. Right now, this has not been granted.
+ return [
+ "738313172788" => "ai-search-boston-gov-91793",
+ "612042612588" => "vertex-ai-poc-406419",
+ ];
+
+ // Get token.
+ try {
+ $headers = [
+ "Authorization" => $this->authenticator->getAccessToken($this->settings[$this->id()]['service_account'], "Bearer"),
+ "Accept" => "application/json",
+ ];
+ }
+ catch (Exception $e) {
+ $this->error = $e->getMessage() ?? "Error getting access token.";
+ return [];
+ }
+
+ $url = GcGenerationURL::build(GcGenerationURL::PROJECT, $this->settings[$this->id()]);
+
+ // Query the AI.
+ $output = [];
+ $post_fields = NULL;
+ $post_fields = "parent=" . urlencode("organizations/593266943271");
+// $post_fields = [
+// "scope" => urlencode("organizations/593266943271"),
+// "assetTypes" => ["cloudresourcemanager.googleapis.com/Project"]
+// ];
+ $results = $this->get($url, $post_fields, $headers);
+ foreach($results["dataStores"] ?: [] as $dataStore) {
+ $dataStoreName = explode("/", $results["dataStores"][0]["name"]);
+ $dataStoreId = array_pop($dataStoreName);
+ // $output[$dataStoreId] = $dataStore['displayName'];
+ $output[$dataStoreId] = $dataStoreId;
+ }
+ return $output;
+
+ }
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php
index a0284d0d4e..56c9f57167 100644
--- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php
+++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php
@@ -385,7 +385,7 @@ private function parseGoogleAddress(array $result): array {
*
* @return array
*/
- public static function ajaxTestService(array $form, FormStateInterface $form_state): array {
+ public static function ajaxTestService(array &$form, FormStateInterface $form_state): array {
$address = new BosGeoAddress();
$address->setSingleLineAddress("1 Cityhall plaza, Boston, MA");
@@ -449,4 +449,27 @@ public function setServiceAccount(string $service_account): GcServiceInterface {
throw new Exception("There is no service account conmcept for geocoder");
}
+ /**
+ * @inheritDoc
+ */
+ public function hasFollowup(): bool {
+ // Not applicable
+ return FALSE;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSettings(): array {
+ return $this->settings[$this->id()];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availablePrompts(): array {
+ // Not implemented
+ return [];
+ }
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php
index 1bd0b240b2..6e5a55b70a 100644
--- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php
+++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php
@@ -4,6 +4,8 @@
use Drupal;
use Drupal\bos_core\Controllers\Curl\BosCurlControllerBase;
+use Drupal\bos_google_cloud\Apis\v1alpha\SearchResponse;
+use Drupal\bos_google_cloud\GcGenerationPrompt;
use Drupal\bos_google_cloud\GcGenerationURL;
use Drupal\bos_google_cloud\GcGenerationPayload;
use Drupal\Core\Config\ConfigFactory;
@@ -16,16 +18,22 @@
use Exception;
/**
- class GcSearch
- Creates a gen-ai search service for bos_google_cloud
+ * Class GcSearch.
+ *
+ * Creates a gen-ai search service for bos_google_cloud - uses Discovery Engine
+ * API to access Vertex Agent Builder apps, engines and datastores.
+ *
+ * david 01 2024
+ *
+ * @extends BosCurlControllerBase
+ * @implements GcServiceInterface, GcAgentBuilderInterface
+ *
+ * @file docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php
+ * @see https://cloud.google.com/generative-ai-app-builder/docs/introduction
+ */
+class GcSearch extends BosCurlControllerBase implements GcServiceInterface, GcAgentBuilderInterface {
- david 01 2024
- @file docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php
-*/
-
-class GcSearch extends BosCurlControllerBase implements GcServiceInterface {
-
- /**
+ /**
* Logger object for class.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
@@ -94,13 +102,31 @@ public function setServiceAccount(string $service_account):GcSearch {
* @return string
* @throws \Exception
*/
- public function execute(array $parameters = []): string {
+ public function execute(array $parameters = []): FALSE|SearchResponse {
+
+ // Verify the minimum information is available.
+ $this->validateQueryParameters($parameters);
+ if ($this->error()) {
+ return $this->error();
+ }
+
+ // Check quota
+ if (GcGenerationURL::quota_exceeded(GcGenerationURL::SEARCH)) {
+ $this->error = "Quota exceeded for Discovery API";
+ return $this->error;
+ }
+
+ // Manage conversations.
+ $allow_conversation = ($this->settings[$this->id()]["allow_conversation"] ?? FALSE && $parameters["allow_conversation"] ?? FALSE);
- $settings = $this->settings["search"] ?? [];
+ // If we have overrides for the default projects or datastores, apply the
+ // override here.
+ $this->overrideModelSettings($parameters);
+ // Get new or cached OAuth2 authorization from GC.
try {
$headers = [
- "Authorization" => $this->authenticator->getAccessToken($settings["service_account"], "Bearer")
+ "Authorization" => $this->authenticator->getAccessToken($this->settings[$this->id()]["service_account"], "Bearer")
];
}
catch (Exception $e) {
@@ -108,23 +134,12 @@ public function execute(array $parameters = []): string {
return $this->error();
}
- if (empty($parameters["search"])) {
- $this->error = "A search request is required.";
- return $this->error();
- }
-
- $parameters["prompt"] = $parameters["prompt"] ?? "default";
- $parameters["text"] = $parameters["search"];
-
- if (GcGenerationURL::quota_exceeded(GcGenerationURL::CONVERSATION)) {
- $this->error = "Quota exceeded for this API";
- return $this->error;
- }
-
- $url = GcGenerationURL::build(GcGenerationURL::CONVERSATION, $settings);
+ // Build the endpoint.
+ $url = GcGenerationURL::build(GcGenerationURL::SEARCH, $this->settings[$this->id()]);
+ // Build the payload (:search).
try {
- if (!$payload = GcGenerationPayload::build(GcGenerationPayload::CONVERSATION, $parameters)) {
+ if (!$payload = GcGenerationPayload::build(GcGenerationPayload::SEARCH, $parameters)) {
$this->error = "Could not build Payload";
return $this->error;
}
@@ -134,67 +149,94 @@ public function execute(array $parameters = []): string {
return $this->error;
}
+ // Run the Query.
$results = $this->post($url, $payload, $headers);
- if ($this->http_code() == 200 && !$this->error()) {
-
- $this->response["search"] = [];
-
- $this->response["search"]["results"] = $results["reply"]["summary"]["summaryWithMetadata"];
- $this->response["search"]["conversation"] = $results["conversation"];
- $this->response["search"]["results"]["webpages"] = $results["searchResults"];
- $this->loadSafetyRatings($results["reply"]["summary"]["safetyAttributes"]);
- unset($this->response["body"]);
-
- if (empty($this->response["search"]["results"]) || $this->error()) {
- $this->error() || $this->error = "Unexpected response from GcSearch";
- return $this->error();
- }
-
- return $this->response["search"]["results"]["summary"];
-
- }
-
- elseif ($this->http_code() == 401) {
+ if ($this->http_code() == 401) {
// The token is invalid, because we are caching for the lifetime of the
// token, this probably means it has been refreshed elsewhere.
- $this->authenticator->invalidateAuthToken($settings["service_account"]);
+ $this->authenticator->invalidateAuthToken($this->settings[$this->id()]["service_account"]);
if (empty($parameters["invalid-retry"])) {
$parameters["invalid-retry"] = 1;
return $this->execute($parameters);
}
- return "";
+ throw new Exception($this->error);
}
- elseif ($this->error()) {
- return "";
+ elseif (empty($results) || $this->error() || $this->http_code() != 200) {
+ if (empty($this->error)) {$this->error = " Unknown Error: ";}
+ $this->error .= ", HTTP-CODE: " . $this->response["http_code"];
+ throw new Exception($this->error);
}
- else {
- $this->error = "Unknown Error: " . $this->response["http_code"];
- return "";
+ // We got some sort of response, so load it into the SearchResponse obejct,
+ // verify it and then remove the "body" element because it is no longer
+ // needed.
+ $this->response["object"] = new SearchResponse($results);
+ if (!$this->response["object"]->validate()) {
+ $this->error() || $this->error = "Unexpected response from GcSearch";
+ return $this->error();
}
+ unset($this->response["body"]);
+
+ if ($allow_conversation) {
+
+ /* When we built the initial Payload, the $allow_conversation = TRUE
+ caused the query to be set up for follow-up questions (by creating a
+ session).
+ The SearchResponse will have returned search results and session info.
+ Now we need to use the sessioninfo get a generated answer with a call
+ to projects.locations.collections.engines.servingconfigs.answer */
+
+ // Fetch the sessionid (and queryid) from the response.
+ $session_id = explode("/", $results["sessionInfo"]["name"]);
+ $session_id = array_pop($session_id);
+ $parameters["session_id"] = $session_id;
+ $query_id = explode("/", $results["sessionInfo"]["queryId"]);
+ $query_id = array_pop($query_id);
+ $parameters["query_id"] = $query_id;
+
+ // Save the search request and response objects for later.
+ // (Calling the post method creates new request & response objects,
+ // overwriting what we currently have.)
+ $this->response["session_id"] = $session_id;
+ $this->response["query_id"] = $query_id;
+ $searchResponse = $this->response;
+ $this->searchRequest = $this->request;
+
+ // Build the endpoint.
+ $url = GcGenerationURL::build(GcGenerationURL::SEARCH_ANSWER, $this->settings[$this->id()]);
+
+ // Build the payload (:answer).
+ try {
+ if (!$payload = GcGenerationPayload::build(GcGenerationPayload::SEARCH_ANSWER, $parameters)) {
+ $this->error = "Could not build Payload";
+ return $this->error;
+ }
+ }
+ catch (Exception $e) {
+ $this->error = $e->getMessage();
+ return $this->error;
+ }
- }
+ // Run the second query.
+ $results = $this->post($url, $payload, $headers);
- /**
- * Update the $this->response["search"]["ratings"] array if the safety scores
- * in $ratings are higher (less safe) than those already stored.
- *
- * @param array $ratings The safetyRatings from a gemini ::predict call.
- *
- * @return void
- */
- private function loadSafetyRatings(array $ratings): void {
+ if (!$results) {
+ throw new \Exception($this->error);
+ }
- if (!isset($this->response["search"]["safetyRatings"])) {
- $this->response["search"]["safetyRatings"] = [];
- }
+ // Merge the Answer Results into the Search Results
+ $this->mergeResults($searchResponse, $results);
+ $this->response = $searchResponse;
- foreach($ratings["categories"] as $key => $rating) {
- $this->response["search"]["safetyRatings"][$rating] = $ratings["scores"][$key];
}
+ // Gather Vertex search metadata.
+ $this->loadMetadata($parameters);
+
+ return $this->response["object"];
+
}
/**
@@ -203,9 +245,11 @@ private function loadSafetyRatings(array $ratings): void {
public function buildForm(array &$form): void {
$project_id="612042612588";
- $model_id="drupalwebsite_1702919119768";
+ $datastore_id="drupalwebsite_1702919119768";
+ $engine_id="oeoi-search-pilot_1726266124376";
$location_id="global";
$endpoint="https://discoveryengine.googleapis.com";
+ $model="stable";
$svs_accounts = [];
foreach ($this->settings["auth"]??[] as $name => $value) {
@@ -214,7 +258,7 @@ public function buildForm(array &$form): void {
}
}
- $settings = $this->settings['search'] ?? [];
+ $settings = $this->settings[$this->id()] ?? [];
$form = $form + [
'search' => [
@@ -236,10 +280,20 @@ public function buildForm(array &$form): void {
'#type' => 'textfield',
'#title' => t('Data Store'),
'#description' => t(''),
- '#default_value' => $settings['datastore_id'] ?? $model_id,
+ '#default_value' => $settings['datastore_id'] ?? $datastore_id,
+ '#required' => TRUE,
+ '#attributes' => [
+ "placeholder" => 'e.g. ' . $datastore_id,
+ ],
+ ],
+ 'engine_id' => [
+ '#type' => 'textfield',
+ '#title' => t('Engine'),
+ '#description' => t(''),
+ '#default_value' => $settings['engine_id'] ?? $engine_id,
'#required' => TRUE,
'#attributes' => [
- "placeholder" => 'e.g. ' . $model_id,
+ "placeholder" => 'e.g. ' . $engine_id,
],
],
'location_id' => [
@@ -263,6 +317,17 @@ public function buildForm(array &$form): void {
"placeholder" => 'e.g. ' . $endpoint,
],
],
+ 'model' => [
+ '#type' => 'select',
+ '#title' => t('The LLM model to use'),
+ '#description' => t('This is the model that will be used.
Best to set to "stable" for latest stable release (which typically is frozen and only updated periodically) or "preview" for the latest model (which is more experimental and can be updated more frequently).
See https://cloud.google.com/generative-ai-app-builder/docs/answer-generation-models#models'),
+ '#default_value' => $settings['model'] ?? $model,
+ '#options' => [
+ 'stable' => 'Stable',
+ 'preview' => 'Preview',
+ ],
+ '#required' => TRUE,
+ ],
'service_account' => [
'#type' => 'select',
'#title' => t('The default service account to use'),
@@ -274,6 +339,13 @@ public function buildForm(array &$form): void {
"placeholder" => 'e.g. ' . ($svs_accounts[0] ?? "No Service Accounts!"),
],
],
+ 'allow_conversation' => [
+ '#type' => 'checkbox',
+ '#title' => t('Allow conversations to continue.'),
+ '#description' => t('If this option is de-selected, previous questions and answers are not considered for context.'),
+ '#default_value' => $settings['allow_conversation'] ?? 0,
+ '#required' => FALSE,
+ ],
'test_wrapper' => [
'test_button' => [
'#type' => 'button',
@@ -309,13 +381,19 @@ public function submitForm(array $form, FormStateInterface $form_state): void {
if ($config->get("search.project_id") !== $values['project_id']
||$config->get("search.datastore_id") !== $values['datastore_id']
+ ||$config->get("search.engine_id") !== $values['engine_id']
||$config->get("search.location_id") !== $values['location_id']
||$config->get("search.service_account") !== $values['service_account']
- ||$config->get("search.endpoint") !== $values['endpoint']) {
+ ||$config->get("search.allow_conversation") !== $values['allow_conversation']
+ ||$config->get("search.endpoint") !== $values['endpoint']
+ ||$config->get("search.model") !== $values['model']) {
$config->set("search.project_id", $values['project_id'])
->set("search.datastore_id", $values['datastore_id'])
+ ->set("search.engine_id", $values['engine_id'])
->set("search.location_id", $values['location_id'])
+ ->set("search.allow_conversation", $values['allow_conversation'])
->set("search.endpoint", $values['endpoint'])
+ ->set("search.model", $values['model'])
->set("search.service_account", $values['service_account'])
->save();
}
@@ -360,4 +438,369 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st
}
+ /**
+ * @inheritDoc
+ */
+ public function hasFollowup(): bool {
+ return TRUE;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSettings(): array {
+ return $this->settings[$this->id()];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function loadMetadata(array $parameters): void {
+
+ if (!$parameters["metadata"]) {
+ return;
+ }
+
+ $service_account = $this->settings[$this->id()]["service_account"];
+
+ // Populate the metadata with everything.
+ $this->response["metadata"] = [
+ "Model" => array_merge($this->settings[$this->id()], [
+ $service_account => [
+ "client_id" => $this->settings["auth"][$service_account]["client_id"],
+ "client_email" => $this->settings["auth"][$service_account]["client_email"],
+ "project_id" => $this->settings["auth"][$service_account]["project_id"],
+ ]
+ ]),
+ "Search Presets" => [],
+ "Search Query Request" => NULL,
+ "Summary Query Request" => $this->request(),
+ "Response" => $this->response(),
+ ];
+ if (property_exists($this, "searchRequest")){
+ $this->response["metadata"]["Search Query Request"] = $this->searchRequest;
+ }
+ else {
+ unset($this->response["metadata"]["Search Query Request"]);
+ }
+
+ // Flatten the SearchResponse object
+ $this->response["metadata"]["Response"]["SearchResponse"] = $this->response["metadata"]["Response"]["object"]->toArray();
+
+ // Remove elements we don't need.
+ unset($this->response["metadata"]["Response"]["object"]);
+ unset($this->response["metadata"]["Response"]["metadata"]);
+
+ }
+
+ /**
+ * @param string|null $service_account *
+ *
+ * @inheritDoc
+ */
+ public function availableProjects(?string $service_account): array {
+ // Todo: adjust permissions in GC so we can scan for projects, then
+ // only return projects which have agent builder enabled.
+ if (!empty($service_account) && $service_account != "default") {
+ $settings['service_account'] = $service_account;
+ }
+ return [
+ "738313172788" => "ai-search-boston-gov-91793",
+ "612042612588" => "vertex-ai-poc-406419",
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availableDataStores(?string $service_account, ?string $project_id): array {
+
+ $settings = $this->settings[$this->id()];
+
+ if (!empty($service_account) && $service_account != "default") {
+ $settings['service_account'] = $service_account;
+ }
+ if (!empty($project_id) && $project_id != "default") {
+ $settings['project_id'] = $project_id;
+ }
+
+ // Get token.
+ try {
+ $headers = [
+ "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer")
+ ];
+ }
+ catch (Exception $e) {
+ $this->error = $e->getMessage() ?? "Error getting access token.";
+ return [];
+ }
+
+ $url = GcGenerationURL::build(GcGenerationURL::DATASTORE, $settings);
+
+ // Query the AI.
+ try {
+ $results = $this->get($url, NULL, $headers);
+ }
+ catch(\Exception $e) {
+ return [];
+ }
+
+ $output = [];
+ foreach($results["dataStores"] ?? [] as $dataStore) {
+ $dataStoreName = explode("/", $dataStore["name"]);
+ $dataStoreId = array_pop($dataStoreName);
+ $output[$dataStoreId] = $dataStore['displayName'];
+ }
+ return $output;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availableEngines(?string $service_account, ?string $project_id): array {
+ // Get token.
+ $settings = $this->settings[$this->id()];
+
+ if (!empty($service_account) && $service_account != "default") {
+ $settings['service_account'] = $service_account;
+ }
+ if (!empty($project_id) && $project_id != "default") {
+ $settings['project_id'] = $project_id;
+ }
+
+ try {
+ $headers = [
+ "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer")
+ ];
+ }
+ catch (Exception $e) {
+ $this->error = $e->getMessage() ?? "Error getting access token.";
+ return [];
+ }
+
+ $url = GcGenerationURL::build(GcGenerationURL::ENGINE, $settings);
+
+ // Query the AI.
+ $output = [];
+ try {
+ $results = $this->get($url, NULL, $headers);
+ }
+ catch(\Exception $e) {}
+
+ foreach($results["engines"] ?: [] as $engine) {
+ $engineName = explode("/", $engine["name"]);
+ $engineId = array_pop($engineName);
+ $output[$engineId] = $engine['displayName'];
+ }
+
+ return $output;
+
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availableApps(?string $service_account, ?string $project_id): array {
+ return $this->availableDataStores($service_account, $project_id);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availablePrompts(): array {
+ return GcGenerationPrompt::getPrompts($this->id());
+ }
+
+ /**
+ * Returns the current session info (if any).
+ * @return array
+ */
+ public function getSessionInfo(): array {
+ return [
+ "query_id" => $this->response["query_id"] ?: NULL,
+ "session_id" => $this->response["session_id"] ?: NULL,
+ ];
+ }
+
+ /********************************************
+ * Helper Functions
+ ********************************************/
+
+ /**
+ * Make an initial check on the parameters array contents.
+ *
+ * @param array $parameters
+ *
+ * @return bool|string|void|null
+ * @throws \Exception
+ */
+
+ private function validateQueryParameters(array &$parameters) {
+
+ if (empty($parameters["text"])) {
+ $this->error = "A search request is required.";
+ }
+ elseif (empty($this->settings[$this->id()])) {
+ $this->error = "The conversation API settings are empty or missing.";
+ }
+
+ // ensure these parameters have a default setting.
+ $parameters["prompt"] = $parameters["prompt"] ?? "default";
+ $parameters["model"] = $this->settings[$this->id()]["model"] ?? "stable";
+
+ }
+
+ /**
+ * Override the model settings with values from parameters["overrides"].
+ *
+ * Copy the svs_settings into parameters array.
+ *
+ * @param array $parameters
+ *
+ * @return void After this method, the svs_settings and parameters should be
+ * synchronized.
+ */
+ private function overrideModelSettings(array &$parameters): void {
+
+ if (!empty($parameters["service_account"])) {
+ $this->settings[$this->id()]['service_account'] = $parameters["service_account"];
+ }
+ else {
+ $parameters["service_account"] = $this->settings[$this->id()]['service_account'];
+ }
+
+ if (!empty($parameters["project_id"])) {
+ $this->settings[$this->id()]['project_id'] = $parameters["project_id"];
+ }
+ else {
+ $parameters["project_id"] = $this->settings[$this->id()]['project_id'];
+ }
+
+ if (!empty($parameters["datastore_id"])) {
+ $this->settings[$this->id()]['datastore_id'] = $parameters["datastore_id"];
+ }
+ else {
+ $parameters["datastore_id"] = $this->settings[$this->id()]['datastore_id'];
+ }
+
+ if (!empty($parameters["engine_id"])) {
+ $this->settings[$this->id()]['engine_id'] = $parameters["engine_id"];
+ }
+ else {
+ $parameters["engine_id"] = $this->settings[$this->id()]['engine_id'];
+ }
+
+ }
+
+ /**
+ * Update the $this->response["search"]["ratings"] array if the safety scores
+ * in $ratings are higher (less safe) than those already stored.
+ *
+ * @param array $ratings The safetyRatings from a gemini ::predict call.
+ *
+ * @return void
+ */
+ private function loadSafetyRatings(array $ratings): void {
+
+ if (!isset($this->response["search"]["safetyRatings"])) {
+ $this->response["search"]["safetyRatings"] = [];
+ }
+
+ foreach($ratings["categories"] ?? [] as $key => $rating) {
+ $this->response["search"]["safetyRatings"][$rating] = $ratings["scores"][$key];
+ }
+
+ }
+
+ /**
+ * Merge an AnswerResponseObject into a ResponseObject
+ *
+ * @param $results
+ *
+ * @return void
+ */
+ private function mergeResults(array &$searchResponse, array $results): void {
+ // Merge these results/response into the original response.
+ $searchResponse["object"]->set("summary", [
+ "summaryText" => $results["answer"]["answerText"], // Summary with citations
+ "safetyAttributes" => [""],
+ "summaryWithMetadata" => [
+ "summary" => $results["answer"]["answerText"], // Summary with no citations
+ "citationMetadata" => [
+ "citations" => $this->reformatCitations($results["answer"]["citations"] ?? []),
+ ],
+ "references" => $this->reformatReferences($results["answer"]["references"] ?? []),
+ ],
+ "extraInfo" => [
+ "queryUnderstandingInfo" => $results["answer"]["queryUnderstandingInfo"] ?? NULL,
+ "answerName" => $results["answer"]["name"],
+ "steps" => $results["answer"]["steps"],
+ "state" => $results["answer"]["state"],
+ "createTime" => $results["answer"]["createTime"] ?? NULL,
+ "completeTime" => $results["answer"]["completeTime"] ?? NULL,
+ "answerSkippedReasons" => $results["answer"]["answerSkippedReasons"] ?? NULL,
+ ]
+ ]);
+ $searchResponse["object"]->set("guidedSearchResult", [
+ "refinementAttributes" => NULL,
+ "followUpQuestions" => $results["answer"]["relatedQuestions"] ?? NULL,
+ ]);
+ $searchResponse["object"]->set("sessionInfo", array_merge($searchResponse["object"]->get("sessionInfo"), $results["session"]));
+
+ // Manage the response object.
+ $searchResponse["elapsedTime"] += $this->response["elapsedTime"];
+ $searchResponse["http_code"] = $this->response["http_code"];
+ $searchResponse["answer_response_raw"] = $this->response["response_raw"];
+ $searchResponse["metadata"] = NULL;
+
+ }
+
+ /**
+ * Reformats the citations in AnswerQueryResponse to the SearchResponse format.
+ *
+ * @param $answerCitations
+ *
+ * @return array
+ */
+ private function reformatCitations($answerCitations): array {
+ $output = [];
+ foreach($answerCitations as $k => $citation) {
+ foreach( $citation["sources"] as $key => $source) {
+ $sources[$key] = ["referenceIndex" => $source["referenceId"]];
+ }
+ $output[$k] = [
+ "startIndex" => $citation["startIndex"] ?? 0,
+ "endIndex" => $citation["endIndex"],
+ "sources" => $sources,
+ ];
+ }
+ return $output;
+ }
+
+ /**
+ * Reformats the citations in AnswerQueryResponse to the SearchResponse format.
+ *
+ * @param $answerCitations
+ *
+ * @return array
+ */
+ private function reformatReferences($answerReferences): array {
+ $output = [];
+ foreach($answerReferences as $reference) {
+ $output[] = [
+ "title" => $reference["chunkInfo"]["documentMetadata"]["title"],
+ "document" => $reference["chunkInfo"]["documentMetadata"]["document"],
+ "uri" => $reference["chunkInfo"]["documentMetadata"]["uri"],
+ "chunkContents" => [
+ "content" => $reference["chunkInfo"]["content"],
+ "pageIdentifier" => NULL,
+ ],
+ "extraInfo" => [
+ "relevanceScore" => $reference["chunkInfo"]["relevanceScore"] ?: NULL,
+ ],
+ ];
+ }
+ return $output;
+ }
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php
index 239743f368..74ca188cde 100644
--- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php
+++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php
@@ -4,6 +4,11 @@
use Drupal\Core\Form\FormStateInterface;
+/**
+ * Interface GcServiceInterface.
+ *
+ * Provides methods to interact with a Google Cloud service.
+ */
interface GcServiceInterface {
/**
@@ -18,14 +23,14 @@ public static function id(): string;
*
* @params string $parameters An array of parameters for this service.
*
- * @return string The output from the service.
+ * @return string|mixed The output from the service.
*
* @description Typically:
* $parameters["text"] - The text string to process
* $parameters["prompt"] - The prompt to use during processing
*
*/
- public function execute(array $parameters = []): string;
+ public function execute(array $parameters = []): object|string|FALSE;
/**
* Build the section on the Goggle Cloud Confrm form for this service.
@@ -79,4 +84,35 @@ public function error(): string|bool;
*/
public function setServiceAccount(string $service_account):GcServiceInterface;
+ /**
+ * Flag whether the service supports an ongoing conversation.
+ *
+ * @return bool TRUE is conversation supported.
+ */
+ public function hasFollowup():bool;
+
+ /**
+ * Return the Google Cloud config for this service.
+ *
+ * @return array
+ */
+ public function getSettings():array;
+
+ /**
+ * Provides a means to test connectivity to this service. Used by the config form.
+ *
+ * @param array $form
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ *
+ * @return array Render array for forms API - message back based on test result.
+ */
+ public static function ajaxTestService(array &$form, FormStateInterface $form_state): array;
+
+ /**
+ * Returns a list of prompts which can be used by this service.
+ *
+ * @return array
+ */
+ public function availablePrompts(): array ;
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php
index eb8938bccf..96353bd2af 100644
--- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php
+++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php
@@ -475,4 +475,24 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st
}
+ /**
+ * @inheritDoc
+ */
+ public function hasFollowup(): bool {
+ return FALSE;
+ }
+ /**
+ * @inheritDoc
+ */
+ public function getSettings(): array {
+ return $this->settings[$this->id()];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availablePrompts(): array {
+ return GcGenerationPrompt::getPrompts($this->id());
+ }
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php
index 2c07f3cf18..d81ee5a8d8 100644
--- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php
+++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php
@@ -502,4 +502,25 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st
}
+ /**
+ * @inheritDoc
+ */
+ public function hasFollowup(): bool {
+ return FALSE;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSettings(): array {
+ return $this->settings[$this->id()];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availablePrompts(): array {
+ return GcGenerationPrompt::getPrompts($this->id());
+ }
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php
index 965a4f913f..6ac69c0526 100644
--- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php
+++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php
@@ -479,4 +479,25 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st
}
+ /**
+ * @inheritDoc
+ */
+ public function hasFollowup(): bool {
+ return FALSE;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSettings(): array {
+ return $this->settings[$this->id()];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function availablePrompts(): array {
+ return GcGenerationPrompt::getPrompts($this->id());
+ }
+
}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/README.md b/docroot/modules/custom/bos_components/modules/bos_search/README.md
new file mode 100644
index 0000000000..cbb2b1ece9
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/README.md
@@ -0,0 +1,39 @@
+# AI-Enabled Search
+This component has several elements;
+- a configuration form,
+- a button,
+- a modal form, and
+- an integration framework to connect to AI services/models.
+
+## Button
+The button is an html element to allow the user to launch the modal form.
+The button is located somewhere on the page using either
+- a **snippet** (so it can be added to the top menu), or
+- a **paragraph** (so it can be added as a page or sidebar component).
+
+The button is permission-aware so the user-group who can access the button can be controlled.
+
+The button has some configuration, so that the AIEngine preset to be used can be selected when embedding the button.
+
+Two buttons on the same page using different presets allows direct comparision of AI models and settings.
+
+## Search Form
+The modal Search Form is where the search is performed.
+
+The Search Form is an AJAX driven form which is deployed as a block, and should be added to the bottom of pages where AI-enabled search is desired.
+
+The Search Form
+- can only be launched from the button,
+- provides a conversation-based search experience,
+- remembers previous searches performed by the user and "picks-up" and continues the conversation from earlier in the session, and
+- is AI Model agnostic.
+
+## Configuration Form
+The Configuration Form allows the administrator to set various behiavors for the Search Form.
+
+The Configuration Form also configures **_Presets_** which are "designers" for the various AI Model integrations.
+A button must define a single preset -and it passes the preset-info to the Search Form.
+
+## Intergration with AI Models
+Standard interfaces are implemented. Custom Drupal AI Model modules which implement these interfaces can send and receive instructions and results with the Search Form (e.g. _bos_google_cloud::GcSearch_).
+This way we can add as many AI Models as we desire hopefully without the need to alter the Search Form, or the component Configuration Form.
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.info.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.info.yml
new file mode 100644
index 0000000000..961cb21481
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.info.yml
@@ -0,0 +1,8 @@
+name: 'AI-enabled Search'
+type: module
+description: 'AI-enabled Search front end component for boston.gov.'
+core_version_requirement: ^10
+package: 'Custom'
+dependencies:
+ - bos_google_cloud
+config_devel: { }
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.libraries.yml
new file mode 100644
index 0000000000..1aa9422402
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.libraries.yml
@@ -0,0 +1,87 @@
+core:
+ css:
+ theme:
+ css/bos_search.css: {}
+ js:
+ js/bos_search.js: {}
+ dependencies:
+ - core/drupal
+ - core/jquery
+ - core/once
+
+component.search_bar:
+ css:
+ theme:
+ css/search_bar.css: { }
+
+component.card:
+ css:
+ theme:
+ css/card.css: { }
+
+component.quote_card:
+ css:
+ theme:
+ css/quote_card.css: { }
+
+component.grid_of_cards:
+ css:
+ theme:
+ css/grid_of_cards.css: { }
+
+snippet.modal_close:
+ css:
+ theme:
+ css/modal_close.css: { }
+ js:
+ js/modal_close.js: {}
+ dependencies:
+ - core/drupal
+ - core/jquery
+ - core/once
+
+snippet.search_feedback:
+ css:
+ theme:
+ css/ai_feedback.css: { }
+ js:
+ js/ai_feedback.js: {}
+ dependencies:
+ - core/drupal
+ - core/jquery
+ - core/once
+ - core/drupal.ajax
+ - webform/webform.ajax
+
+snippet.search_button:
+ css:
+ theme:
+ css/ai_searchbutton.css: { }
+ js:
+ js/ai_searchbutton.js: {}
+ dependencies:
+ - core/drupal
+ - core/jquery
+ - core/once
+
+dynamic-loader:
+ js:
+ js/dynamic_loader.js: { }
+ dependencies:
+ - core/drupal
+ - core/jquery
+ - core/once
+
+disclaimer:
+ version: 1.0
+ css:
+ theme:
+ css/disclaimer.css: { }
+ js:
+ js/disclaimer.js: {}
+ dependencies:
+ - core/drupal
+ - core/drupalSettings
+ - core/jquery
+ - core/drupal.dialog
+ - core/drupal.dialog.ajax
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.menu.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.menu.yml
new file mode 100644
index 0000000000..6ac768423d
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.menu.yml
@@ -0,0 +1,6 @@
+bos_search.AiSearchConfigForm:
+ title: 'AI-enabled Search'
+ description: 'Configure AI-enabled Search for boston.gov.'
+ parent: bos_core.admin
+ route_name: bos_search.AiSearchConfigForm
+ weight: 0
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.task.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.task.yml
new file mode 100644
index 0000000000..f6cc1dd88b
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.task.yml
@@ -0,0 +1,5 @@
+bos_search.AiSearchConfigForm:
+ title: 'AI-enabled Search'
+ route_name: bos_search.AiSearchConfigForm
+ base_route: bos_core.admin
+ weight: 2
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.module b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.module
new file mode 100644
index 0000000000..d5d9af9a4f
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.module
@@ -0,0 +1,67 @@
+ [
+ 'base hook' => 'form',
+ 'template' => 'form-element--webform-checkbox',
+ ],
+ 'aisearch_button' => [
+ 'template' => 'snippets/aisearch-button',
+ 'variables' => [
+ 'search_form_url' => '/search',
+ 'button_title' => '',
+ 'button_css' => '',
+ 'preset' => '',
+ 'preset_theme' => '',
+ 'display' => '',
+ 'body' => '',
+ ],
+ ],
+
+ ];
+
+ // Discover dynamic aisearch templates and themes from this module
+ _bos_search_autodiscover_theme($output);
+ _bos_search_snippet_theme($output);
+
+ // Load component themes.
+ // TODO: This should be moved to the themes hook_theme function.
+ _load_component_definitions($output);
+
+ return $output;
+
+}
+
+function bos_search_theme_suggestions_alter(array &$suggestions, array &$variables, $hook):void {
+ if (AiSearch::isBosSearchThemed()) {
+ _search_form_suggestions($suggestions, $variables, $hook);
+ }
+ if ($hook == "form_element" && in_array("form_element__webform_checkbox",$suggestions)) {
+ $suggestions[] = "form_element__bos_search__disclaimer__webform_checkbox";
+ }
+}
+
+function bos_search_preprocess_search_bar(&$variables):void {
+ _bos_search_preprocess_search_bar($variables);
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.permissions.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.permissions.yml
new file mode 100644
index 0000000000..2e7b2ae1b1
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.permissions.yml
@@ -0,0 +1,9 @@
+'view ai-enabled search permission':
+ title: 'View AI-Enabled Search Component'
+ description: 'Use the AI-enabled search to search the site content.'
+ restrict access: false
+
+'administer ai-enabled search permission':
+ title: 'Administer AI-Enabled Search Component'
+ description: 'Administer the AI-enabled search component.'
+ restrict access: true
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.routing.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.routing.yml
new file mode 100644
index 0000000000..cd4d1e3f32
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.routing.yml
@@ -0,0 +1,35 @@
+bos_search.AiSearchConfigForm:
+ path: 'admin/config/system/boston/aisearch'
+ defaults:
+ _form: '\Drupal\bos_search\Form\AiSearchConfigForm'
+ _title: 'Search Configuration Form'
+ requirements:
+ _permission: 'administer ai-enabled search permission'
+
+bos_search.open_AISearchForm:
+ path: 'admin/config/system/boston/aisearch/AiSearchForm'
+ defaults:
+ _controller: '\Drupal\bos_search\Controller\AiSearchFormController::openModalForm'
+ _title: 'Search boston.gov'
+ requirements:
+ _permission: 'view ai-enabled search permission'
+ options:
+ _admin_route: TRUE
+
+bos_search.open_DisclaimerForm:
+ path: 'admin/config/system/boston/aisearch/AiDisclaimerForm'
+ defaults:
+ _controller: '\Drupal\bos_search\Controller\AiSearchFormController::openDisclaimerForm'
+ _title: 'AI Disclaimer'
+ requirements:
+ _permission: 'view ai-enabled search permission'
+ options:
+ _admin_route: TRUE
+
+bos_search.autocomplete_nodes:
+ path: '/bos_search_autocomplete_nodes'
+ defaults:
+ _controller: '\Drupal\bos_search\Controller\AutocompleteController::searchNodes'
+ _title: 'Autocomplete'
+ requirements:
+ _access: 'TRUE'
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.services.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.services.yml
new file mode 100644
index 0000000000..6075979253
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.services.yml
@@ -0,0 +1,11 @@
+services:
+ bos_search.callbacks:
+ class: Drupal\bos_search\AiSearchFormCallbacks
+ arguments: ['@entity_type.manager', '@form_builder']
+
+ plugin.manager.aisearch:
+ class: Drupal\bos_search\Plugin\AiSearch\AiSearchPluginManager
+ parent: default_plugin_manager
+
+ Drupal\bos_search\Twig\CustomFiltersExtension:
+ tags: ['twig.extension']
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/ai_feedback.css b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_feedback.css
new file mode 100644
index 0000000000..e6678aeb49
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_feedback.css
@@ -0,0 +1,222 @@
+.ai-feedback-wrapper {
+ color: #58585B;
+}
+.ai-feedback-buttons {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ padding: 16px 16px 16px 8px;
+ border: 1px solid #C4C1C1;
+}
+.ai-feedback-buttons .thumbsdown,
+.ai-feedback-buttons .thumbsup {
+ max-height: 20px;
+}
+.ai-feedback-item.speaker {
+ margin-right:32px;
+}
+.ai-feedback-item {
+ cursor: pointer;
+ line-height: 32px;
+ padding:8px;
+}
+.ai-feedback-item .ai-feedback-svg {
+ fill: #091f2f;
+}
+.ai-feedback-item:active .ai-feedback-svg {
+ fill: #FFFFFF;
+}
+.ai-feedback-item:focus .ai-feedback-svg,
+.ai-feedback-item:hover .ai-feedback-svg {
+ fill: #288BE4;
+}
+.ai-feedback-item:hover:not(:disabled), .button:focus:not(:disabled), .button.ai-feedback-item {
+ background-color: transparent;
+}
+.ai-feedback-item:active {
+ background-color: #288be4;
+}
+.ai-feedback-text {display: block;}
+.ai-feedback-confirm {
+ display: none;
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 1em;
+ line-height: 1.75em;
+ color: #58585B;
+ font-weight: 700;
+ background-color: rgb(40,139,228,0.10);
+ width: fit-content;
+ padding: 16px 32px 16px 16px;
+ border-radius: 4px;
+}
+.feedback-dialog .ui-dialog-titlebar {
+ background-image: none;
+ background-color: transparent;
+ border: none;
+}
+.feedback-dialog {
+ font-size: 16px;
+ line-height: 25px;
+ font-family: 'Lora', 'Georgia', serif;
+ letter-spacing: 0.5px;
+ color: #58585B;
+}
+.feedback-dialog #drupal-modal.ui-dialog-content {
+ padding: 0 16px;
+ margin-top: 32px;
+}
+.feedback-dialog .ui-dialog-title {display:none;}
+.feedback-dialog .ui-dialog-titlebar-close {
+ border:none;
+ background-color: transparent;
+ width: 36px;
+ height: 36px;
+ margin: 4px;
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+.feedback-dialog .ui-dialog-titlebar-close:active,
+.feedback-dialog .ui-dialog-titlebar-close:hover {
+}
+.feedback-dialog .ui-icon-closethick,
+.feedback-dialog .ui-icon-closethick:active,
+.feedback-dialog .ui-icon-closethick:hover {
+ background-image: url(../img/x.svg);
+ background-position: 0;
+ background-size: 13px;
+}
+.feedback-dialog .ui-button:hover .ui-icon,
+.feedback-dialog .ui-button:focus .ui-icon {
+ background-image: url(../img/x.svg);
+}
+.feedback-dialog .fieldgroup legend {
+ margin-bottom: 16px;
+}
+.feedback-dialog legend .fieldset-legend {
+ font-size: calc(1em + 2px);
+ line-height: calc((1em + 2px) * 1.5);
+ font-weight: 700;
+ font-family: Montserrat, Arial, sans-serif;
+ color: #091f2f;
+}
+.feedback-dialog .form-item.checkboxes--wrapper {
+ height: fit-content;
+}
+.feedback-dialog .form-type-checkbox {
+ margin: 0 12px 16px 0;
+ padding: 0;
+ max-height: 32px;
+}
+.feedback-dialog .form-type-checkbox.form-item label {
+ display: initial;
+ top: -9px;
+ line-height: 50px;
+ font-size: 1em;
+ text-transform: none;
+ font-weight: 400;
+ font-family: inherit;
+ color: #58585B;
+ position: relative;
+ letter-spacing: 0.5px;
+}
+.feedback-dialog .form-type-checkbox.form-item input[type=checkbox] {
+ display: initial;
+ width: 32px;
+ margin: 0 10px 0 0;
+ max-height: 32px;
+}
+.feedback-dialog .form-type-textarea.form-item label {
+ display: initial;
+ line-height: 50px;
+ font-size: calc(1em + 2px);
+ font-weight: 700;
+ font-family: Montserrat, Arial, sans-serif;
+ color: #091f2f;
+ text-transform: none;
+}
+.feedback-dialog .form-type-textarea.form-item textarea {
+ font-size: 1em;
+ line-height: 25px;
+ font-family: 'Lora', 'Georgia', serif;
+ letter-spacing: 0.5px;
+ padding: 8px;
+}
+.feedback-dialog .webform-element-help {
+ color: #ffffff;
+ background-color: #091f2f;
+ border-color: #091f2f;
+ font-weight: bold;
+ font-family: Montserrat, Arial, sans-serif;
+ position: relative;
+}
+.feedback-dialog .form-item .tippy-box {
+ background-color: #f6f6f6;
+}
+.feedback-dialog .form-item .tippy-box .tippy-content {}
+.feedback-dialog .form-item .tippy-box .webform-element-help--title {
+ color: #288BE4;
+}
+.feedback-dialog .form-item .tippy-box .webform-element-help--content {
+ text-transform: none;
+ color: #58585B;
+}
+.feedback-dialog .text-count-message {
+ font-style: italic;
+ color: #58585B;
+ font-weight: 400;
+}
+.feedback-dialog .ui-dialog-buttonpane {
+ border: 0;
+ padding: 12px 16px;
+}
+.feedback-dialog .ui-dialog-buttonpane .form-actions {float: none;width: 100%;}
+.feedback-dialog .ui-dialog-buttonpane .webform-button--submit {
+ width: 100%;
+ font-family: Montserrat, Arial, sans-serif;
+ letter-spacing: 2px;
+}
+.feedback-dialog .ui-dialog-buttonpane .webform-button--submit:focus,
+.feedback-dialog .ui-dialog-buttonpane .webform-button--submit:hover,
+.feedback-dialog .ui-dialog-buttonpane .webform-button--submit:active {
+ border: 0;
+ font-weight: bold;
+ background-color: #fb4d42;
+ color: #fff;
+}
+.feedback-dialog form .b--p300 {padding: 0;margin: 0;width: 100%;}
+.feedback-dialog #system-messages .messages__icon {display:none;}
+.feedback-dialog #system-messages {padding: 16px;margin-bottom: 20px;}
+.feedback-dialog #system-messages .messages--item {
+ font-size: 1em;
+ line-height: 25px;
+ font-family: 'Lora', 'Georgia', serif;
+}
+.feedback-dialog #system-messages .message--button {position: absolute;right: 16px;top: 16px;}
+
+.feedback-dialog .webform-actions .ajax-progress {
+ display:none;
+}
+
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+@media screen and (min-width: 480px) {
+ .ai-feedback-wrapper {
+ }
+ .feedback-dialog {
+ max-width: 410px;
+ width: 410px !important;
+ left: calc((100vw / 2) - 205px) !important;
+ }
+ .feedback-dialog #drupal-modal.ui-dialog-content {
+ padding: 0 42px;
+ }
+ .feedback-dialog .ui-dialog-buttonpane {
+ border: 0;
+ margin: 12px 0 12px 0;
+ padding: 0 42px;
+ }
+}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/ai_searchbutton.css b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_searchbutton.css
new file mode 100644
index 0000000000..218e6a2399
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_searchbutton.css
@@ -0,0 +1,47 @@
+.aienabledsearchbutton-wrapper {
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 16px;
+ line-height: calc(16px * 1.75);
+ color: #58585B;
+ font-weight: 400;
+ background-color: #f2f2f2;
+}
+.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button {
+ background-color: #288be4;
+ color: #ffffff;
+ max-width: fit-content;
+ position: relative;
+ padding-left: 47px;
+}
+.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:hover { background-color: #1376CF;}
+.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:focus,
+.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:active { background-color: #0062BB;}
+.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:disabled { background-color: #d2d2d2; color: #58585b; }
+
+.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:before {
+ content: "";
+ background-image: url(../img/twinkle_white.svg);
+ height: 24px;
+ width: 24px;
+ background-size: 24px;
+ background-repeat: no-repeat;
+ position: absolute;
+ top: 13px;
+ left: 12px;
+}
+.aienabledsearchbutton-wrapper .aienabledsearchbutton legend {
+ width: fit-content;
+ font-size: calc(1em + 2px);
+ max-width: 1000px;
+ margin: 0 0 16px 0;
+ padding : 0;
+
+}
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+@media screen and (min-width: 480px) {
+
+}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/bos_search.css b/docroot/modules/custom/bos_components/modules/bos_search/css/bos_search.css
new file mode 100644
index 0000000000..1da727822c
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/bos_search.css
@@ -0,0 +1,528 @@
+/**
+ * OVERLAY
+ */
+.ui-widget-overlay.ui-front {
+ background-color: #1871bd;
+ opacity: .5;
+}
+
+/***
+ * MOBILE FIRST CSS.
+ */
+
+/**
+ * GENERAL FORM SETTINGS
+ * .aienabledsearchform is the outermost dimension of the search form = container
+ * .aisearch-modal-form (on .aienabledsearchform) means in a modal dialog window
+ * Rules apply to all screen sizes with @media (min-width: 480px) denoting breakpoint for (responsive) desktop
+ */
+.aienabledsearchform {
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 16px;
+ line-height: 26px;
+ letter-spacing: 0.5px;
+ padding: 0;
+ margin: 0;
+ position: relative;
+}
+/**
+ * MODAL DIALOG WINDOW
+ */
+.aienabledsearchform.aisearch-modal-form {
+ left: 1% !important;
+ top: 5px !important;
+ width: 98% !important;
+ z-index: 550 !important;
+}
+#drupal-modal {
+ padding: 0;
+}
+.aisearch-modal-form.ui-dialog.ui-corner-all {
+ /* place border around the modal dialog */
+ border-radius: 10px;
+ padding-bottom: 0;
+}
+
+/**
+ * MODAL DIALOG TITLEBAR
+ */
+.aisearch-modal-form .ui-dialog-titlebar {
+}
+.aisearch-modal-form .ui-titlebar-hidden {
+ display:none;
+}
+.aisearch-modal-form .ui-dialog-titlebar .ui-dialog-title {}
+.aisearch-modal-form .ui-dialog-titlebar .ui-dialog-titlebar-close {}
+.aisearch-modal-form .ui-dialog-titlebar .ui-icon-closethick {}
+
+/**
+ * Set modal window button states
+ */
+.aisearch-modal-form .ai-form-reset {
+}
+.aisearch-modal-form .ai-reset-button {
+ background-color: rgba(40, 139, 228, 0.1);
+ border-radius: 10px
+}
+.aisearch-modal-form .ai-reset-button:hover {
+ background-color: rgba(40, 139, 228, 0.2);
+}
+.aisearch-modal-form .ai-reset-button:active {
+ background-color: rgba(40, 139, 228, 0.4);
+}
+
+/**
+ * FORM MAIN CONTENT
+ */
+/**
+ * Cards
+ */
+#welcome-cards #edit-cards {
+ margin-top: 0;
+ font-size: 16px;
+}
+#welcome-cards #edit-cards .goc-wrapper {
+ margin-bottom: 16px;
+}
+#welcome-cards #edit-cards .goc-grid {
+ margin-top: 31px;
+}
+#welcome-cards #edit-cards .goc-title {
+ font-size: calc(1em - 2px);
+}
+/**
+ * Search Results area
+ */
+.bos-search-aisearchform #edit-searchresults {margin: 50px 0;}
+.bos-search-aisearchform .search-results-outer-wrapper {
+ margin-top: 36px;
+ padding: 0 10px;
+}
+#drupal-modal.no-welcome #edit-welcome {
+ display:none;
+}
+
+/**
+ * Original Question
+ */
+.bos-search-aisearchform .search-request-wrapper {
+ margin: 78px 0 24px 0;
+ width: 100%;
+}
+.bos-search-aisearchform .search-request {
+ background-color: rgba(24, 113, 189, 0.1); /* Optimistic Blue 10% */
+ border: 1px solid rgba(24, 113, 189, 0.7); /* Optimistic Blue 70% */
+ border-radius: 12px 0 12px 12px;
+ color: #091f2f; /* Charles Blue */
+ padding: 16px 8px;
+ font-style: normal;
+ text-align: right;
+ margin: 0 0 0 0;
+ max-width: 85%;
+ float: right;
+ overflow-wrap: anywhere;
+}
+.bos-search-aisearchform .search-request-progress-wrapper {
+ padding-top: 32px;
+}
+.bos-search-aisearchform .search-request-progress:last-child {
+ display: none;
+}
+
+.aienabledsearchform #search-conversation-wrapper .search-request-wrapper .search-request-progress {
+ background-image: url(../img/loading1_550.gif);
+ width: 100%;
+ background-size: 100% 75%;
+ background-repeat: no-repeat;
+ height: calc(80vw * 300 / 550);
+ opacity: 0.2;
+}
+
+/**
+ * Long Answer
+ */
+.bos-search-aisearchform .search-response-wrapper {
+ margin: 24px 0;
+ max-width: 100%;
+ width: 100%;
+ padding: 0 0 0 0;
+ display: flex;
+ flex-flow: column;
+}
+.bos-search-aisearchform .search-response {
+ padding: 0;
+ color: #585858;
+ font-size: 1em;
+ margin-bottom: 24px;
+}
+.bos-search-aisearchform .search-response-title {
+ padding-left: 42px;
+ margin-bottom: 24px;
+ font-size: calc(1em + 2px);
+ font-family: Montserrat, Arial, sans-serif;
+ font-weight: 700;
+ color: #091F2F;
+ letter-spacing: 1px;
+ padding-top: 12px;
+}
+.bos-search-aisearchform .search-response-title:before {
+ content: "";
+ background-image: url('../img/twinkle.svg');
+ background-repeat: no-repeat;
+ position: absolute;
+ height: 32px;
+ width: 32px;
+ margin-left: -42px;
+ margin-top: -8px;
+}
+/**
+ * Citations
+ */
+.aienabledsearchform .search-citations-title {
+ display:none;
+ color: #091f2f;
+}
+.aienabledsearchform .search-citations-drawer .dr-c {
+ margin: 0;
+ padding: 16px 0;
+}
+.aienabledsearchform .search-citations-drawer .search-citation {
+ margin: 0 0 2px 0;
+ display: block;
+ font-size: 1em;
+ font-family: 'Lora', 'Georgia', serif;
+ line-height: calc(1em * 1.3);
+ text-decoration: underline;
+ -webkit-box-orient: vertical;
+ color: #58585b;
+ padding: 16px;
+}
+.aienabledsearchform .search-citations-drawer.show-more .search-citation:hover {
+ color: #1871bd;
+ background-color: #F2F2F2;
+ border-radius: 2px;
+}
+.aienabledsearchform .search-citations-drawer.show-more .search-citation:focus,
+.aienabledsearchform .search-citations-drawer.show-more .search-citation:active {
+ color: #175182;
+ background-color: #f2f2f2;
+ border-radius: 2px;
+}
+/**
+ * List of links
+ */
+.bos-search-aisearchform .search-results-wrapper {
+ margin-top: 24px;
+ padding: 0 0 0 0;
+ margin-bottom: 24px;
+}
+.bos-search-aisearchform .search-results-wrapper .results-title {
+ font-size: calc(1em + 2px);
+ line-height: calc((1em + 2px) * 1.3);
+ margin-bottom: 24px;
+}
+.bos-search-aisearchform .search-result-wrapper {
+ padding: 16px 12px 16px 0;
+ max-width: 100%;
+ width: 100%;
+}
+.bos-search-aisearchform .search-result-wrapper:hover {
+ background-color: #F2F2F2;
+ margin-left: -12px;
+ padding-left: 12px;
+ width: calc(100% + 12px);
+ max-width: calc(100% + 12px);
+}
+.bos-search-aisearchform .search-result-wrapper:active {
+ background-color: rgba(40,139,228,0.10);
+}
+.bos-search-aisearchform .search-result-wrapper:visited {}
+.bos-search-aisearchform .search-result-title {
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: calc(1em + 2px);
+ line-height: calc((1em + 2px) * 1.2);
+ font-weight: 700;
+ margin-bottom: 12px;
+}
+.bos-search-aisearchform .search-result-title {
+ color: #1871bd; /* Charles Blue */
+ height: 100%;
+ display: block;
+ margin-bottom: 5px;
+ text-decoration: none;
+}
+.bos-search-aisearchform .search-result-sub,
+.bos-search-aisearchform .search-result {
+ font-size: 1em;
+ line-height: 1.75em;
+ color: #58585B;
+ margin-bottom: 12px;
+}
+.bos-search-aisearchform .search-result-sub {
+ font-size: calc(1em - 2px);
+}
+.bos-search-aisearchform .search-result-link-mobile {
+ display: none;
+ text-decoration: underline;
+ color: #58585b;
+}
+.bos-search-aisearchform .search-result-link {
+ display: block;
+ color: #58585B;
+ text-decoration: underline;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+}
+.bos-search-aisearchform .ajax-progress {
+ margin-left: 10%;
+}
+/**
+ * GENERAL
+ */
+.bos-search-aisearchform .br--4 {
+ border-radius: 4px;
+}
+.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper {
+ padding: 12px;
+ margin: 0;
+ height: 100%;
+ display: flex;
+ align-items: center;
+}
+.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb {
+ background-color: rgba(40, 139, 228, 0.1);
+ border: 0 solid rgba(40,139,228,0.1);
+}
+.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:focus-visible,
+.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:focus,
+.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:hover {
+ background-color: rgba(40, 139, 228, 0.2);
+ border: 0px solid rgba(40,139,228,0.2);
+}
+.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:active {
+ background-color: rgba(40, 139, 228, 0.4);
+ border: 0px solid rgba(40, 139, 228, 0.4);
+}
+.bos-search-aisearchform #welcome-cards #edit-cards .card-content-wrapper {
+ padding: 0;
+ margin: auto 0;
+ height: max-content;
+ height: auto;
+ width: 100%;
+}
+.bos-search-aisearchform #welcome-cards #edit-cards .card-content {
+ padding: 0;
+ margin: 0 auto;
+}
+
+.bos-search-aisearchform #welcome-copy {
+ width: 100%;
+ margin-top: 5%;
+ margin-bottom: 35px;
+}
+.bos-search-aisearchform #welcome-title {
+ font-family: 'Montserrat', Arial, sans-serif;
+ letter-spacing: 0;
+ color: #091f2f;
+ font-weight: 600;
+ font-size: calc(1em + 14px);
+ line-height: calc(1em + 14px);
+}
+.bos-search-aisearchform #welcome-body {
+ font-size: 1em;
+ margin-top: 31px;
+ margin-bottom: 31px;
+}
+.bos-search-aisearchform .ajax-progress .message {
+ font-family: 'Montserrat', Arial, sans-serif;
+ letter-spacing: 1px;
+ color: #989898;
+ font-size: calc(1em + 2px);
+}
+.bos-search-aisearchform .ajax-progress-throbber .throbber {
+ background-size: 24px;
+ padding-right: 18px;
+}
+
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+
+@media screen and (min-width: 480px) {
+ .bos-search-aisearchform {
+ }
+ #drupal-modal {
+ padding: .5em 1em;
+ }
+ .aienabledsearchform #search-conversation-wrapper .search-request-wrapper div.search-request {
+ max-width: 60%;
+ margin: 0;
+ width: fit-content;
+ float: right;
+ position: relative;
+ /* z-index: 11; */
+ }
+ .aienabledsearchform #search-conversation-wrapper .search-request-wrapper .search-request {
+ width: 100%;
+ margin: 0 0 0 10%;
+ }
+ .aienabledsearchform #search-conversation-wrapper .search-request-wrapper .search-request-progress {
+ background-image: url(../img/loading1_550.gif);
+ width: 50%;
+ background-size: 97% 75%;
+ background-repeat: no-repeat;
+ height: calc(.67 * 300px);
+ clear: both;
+ opacity: .2;
+ }
+ .bos-search-aisearchform .search-response {
+ flex-basis: 60%;
+ padding-right: 40px;
+ flex-grow: 0;
+ }
+ .bos-search-aisearchform .search-response:before {
+ left: revert;
+ margin-top: -48px;
+ padding-left: calc(32px + 16px);
+ }
+ .bos-search-aisearchform #edit-welcome {
+ }
+ .bos-search-aisearchform #welcome-copy {
+ max-width: 80%;
+ margin: 0 auto;
+ }
+ .bos-search-aisearchform #welcome-title {
+ margin: 0 0 48px 0;
+ padding: 0;
+ font-size: calc(1em + 30px);
+ line-height: normal;
+ }
+ .bos-search-aisearchform #welcome-body {
+ font-size: calc(1em + 2px);
+ margin: 0;
+ padding: 0;
+ }
+ .bos-search-aisearchform #welcome-cards {
+ margin: 24px 0 0 0;
+ padding: 0;
+ }
+ .bos-search-aisearchform .search-results-wrapper {
+ max-width: 100%;
+ width: 100%;
+ }
+ .bos-search-aisearchform .search-result-wrapper {
+ padding: 12px 12px 12px 0;
+ margin-bottom: 22px;
+ margin-top: 22px;
+ }
+ .bos-search-aisearchform .search-result-title a {
+ margin-bottom: 0;
+ }
+ .bos-search-aisearchform .search-result-sub,
+ .bos-search-aisearchform .search-result {
+ padding-left: 0;
+ margin-top: 12px;
+ }
+ .bos-search-aisearchform .search-result-link-mobile {
+ display: none;
+ }
+ .bos-search-aisearchform .search-result-link {
+ display: block;
+ }
+ .bos-search-aisearchform .ajax-progress {
+ margin-left: 10%;
+ }
+ .bos-search-aisearchform #welcome-cards #edit-cards .goc-grid {
+ column-gap: 30px;
+ align-items: stretch;
+ flex-wrap: nowrap;
+ }
+ .bos-search-aisearchform .bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper {
+ padding: 12px 16px;
+ margin: 0 0 0 0;
+ }
+
+ .bos-search-aisearchform #welcome-cards #edit-cards .card-content-wrapper {
+ margin: 0;
+ padding: 0 4px;
+ display: block;
+ }
+
+ .bos-search-aisearchform #welcome-cards #edit-cards .card-content {
+ padding: 26px 0;
+ text-align: left;
+ }
+ .bos-search-aisearchform #welcome-cards #edit-cards .goc-wrapper {
+ margin-bottom: 132px;
+ }
+ .bos-search-aisearchform #welcome-cards #edit-cards .goc-title {
+ font-size: calc(1em);
+ }
+ .bos-search-aisearchform #welcome-cards #edit-cards .grid-of-cards, .grid-of-cards.b--fw {
+ margin:0;
+ }
+ .bos-search-aisearchform #welcome-cards #edit-cards {
+ width: 100%;
+ margin-top: 13px;
+ }
+ .bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper {
+ height: auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ .bos-search-aisearchform .search-request-wrapper {
+ width: 100%;
+ }
+ .bos-search-aisearchform .search-request-progress-wrapper {
+ display: flex;
+ flex-flow: row;
+ align-items: stretch;
+ padding-top: 48px;
+ }
+ .bos-search-aisearchform .search-request-progress:last-child {
+ display: block;
+ }
+ .aienabledsearchform .search-response-wrapper {
+ flex-flow: row;
+ flex-wrap: wrap;
+ padding: 0;
+ font-size: calc(1em + 2px);
+ line-height: calc((1em + 2px)* 1.5);
+ margin-top: 36px;
+ }
+ .aienabledsearchform .search-citations-wrapper {
+ flex-basis: 40%;
+ flex-grow: 0;
+ background-color: #f9f9f9;
+ }
+ .aienabledsearchform .search-citations-wrapper.hide-citation {
+ background-color: transparent;
+ }
+ .aienabledsearchform .search-citations-wrapper:after {
+ clear: both;
+ content: "";
+ display: table;
+ }
+ .aienabledsearchform .search-citations-title {
+ display:block;
+ font-size: calc(1em + 2px);
+ font-weight: 700;
+ font-family: 'Montserrat', Arial, sans-serif;
+ letter-spacing: 1px;
+ padding: 16px;
+ border-bottom: 2px solid #d2d2d2;
+ }
+ .aienabledsearchform .search-citations-drawer .dr-c,
+ .aienabledsearchform .search-citations-drawer {
+ background: initial;
+ }
+ .aienabledsearchform .search-citations-drawer label {
+ display:none;
+ }
+ .aienabledsearchform .search-citations-drawer .dr-c {
+ display:block;
+ padding: 16px 0 16px 0;
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/card.css b/docroot/modules/custom/bos_components/modules/bos_search/css/card.css
new file mode 100644
index 0000000000..35e952a1e7
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/card.css
@@ -0,0 +1,26 @@
+.goc-grid .card {}
+.goc-grid .card-wrapper {
+ padding: 12px 16px;
+ border-radius: 4px;
+ border: none;
+}
+.goc-grid .card-wrapper {
+ border: none;
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 1rem;
+ height: fit-content;
+}
+.goc-grid .card-title {}
+.goc-grid .card-subtitle {}
+.goc-grid .card-wrapper div.card-content-wrapper {
+ padding: 0;
+ height: 120px;
+ text-align: center;
+ border: none;
+}
+.goc-grid .card-wrapper .card-content-wrapper div.card-content {
+ color: #091f2f; /* Charles Blue */
+ margin: auto 20px;
+ line-height: 1.2rem;
+}
+.goc-grid .card-image {}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/disclaimer.css b/docroot/modules/custom/bos_components/modules/bos_search/css/disclaimer.css
new file mode 100644
index 0000000000..a23304c818
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/disclaimer.css
@@ -0,0 +1,145 @@
+.aienableddisclaimerform {
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 16px;
+ line-height: 26px;
+ letter-spacing: 0.5px;
+ color: #58585b;
+ padding: 68px 32px 42px 32px;
+ border-radius: 2px;
+ width: 326px !important;
+ border: none !important;
+}
+.aienableddisclaimerform .ui-titlebar-hidden.ui-dialog-titlebar {
+ display: none;
+ position: absolute;
+ top: calc((40px - 13px) / 2 );
+ right: calc((40px - 13px) / 2 );
+ background-color: transparent;
+ border: none;
+ height: 40px;
+ width: 326px;
+}
+.aienableddisclaimerform .ui-titlebar-hidden .ui-dialog-title {
+ display: none;
+}
+.aienableddisclaimerform .ui-titlebar-hidden .ui-dialog-titlebar-close {
+ background-color: transparent;
+ border: none;
+ width: 26px;
+ height: 26px;
+ top: 0;
+ margin: 0;
+ padding: 0;
+ right: 0;
+}
+.aienableddisclaimerform .ui-titlebar-hidden .ui-icon-closethick {
+ background-image: url("../img/x.svg") ;
+ background-size: 13px;
+ background-position: unset;
+}
+.aienableddisclaimerform #drupal-modal.ui-dialog-content {
+ padding: 0;
+ margin: 0;
+}
+.aienableddisclaimerform .search-disclaimer-wrapper {
+ width: 263px;
+}
+.aienableddisclaimerform .search-disclaimer-wrapper .h2 {
+ font-family: 'Montserrat', Arial, sans-serif;
+ font-size: calc(1em + 14px);
+ letter-spacing: 1px;
+ line-height: 25px;
+ font-weight: 700;
+ color: #091F2F;
+}
+.aienableddisclaimerform .search-disclaimer-wrapper .search-disclaimer-text {
+ font-size: 1em;
+ line-height: 25px;
+ margin-top: 16px;
+ margin-bottom: 40px;
+ padding: 0;
+}
+.aienableddisclaimerform .button {
+ font-family: 'Montserrat', Arial, sans-serif;
+ letter-spacing: 1px;
+ width: 100%;
+ height: 50px;
+ padding: 10px;
+ border: 0 transparent solid;
+ border-radius: 4px;
+}
+.aienableddisclaimerform .form-submit,
+.aienableddisclaimerform .form-submit:visited {
+ background-color: #288BE4;
+ color: #FFFFFF;
+ outline: none;
+}
+.aienableddisclaimerform .form-submit:focus,
+.aienableddisclaimerform .form-submit:hover {
+ background-color: #1376CF;
+ color: #FFFFFF;
+}
+.aienableddisclaimerform .form-submit:active {
+ background-color: #0062BB;
+ color: #FFFFFF;
+}
+.aienableddisclaimerform .btn-cancel,
+.aienableddisclaimerform .btn-cancel:visited {
+ margin-top: 24px;
+ background-color: transparent;
+ color: #091f2f;
+}
+.aienableddisclaimerform .btn-cancel:focus,
+.aienableddisclaimerform .btn-cancel:hover {
+ background-color: inherit;
+ color: #1376CF;
+}
+.aienableddisclaimerform .btn-cancel:active {
+ background-color: inherit;
+ color: #1376CF;
+ border: 2px #1376cf solid;
+ border-radius: 2px;
+}
+
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+
+@media screen and (min-width: 480px) {
+ .aienableddisclaimerform {
+ width: 591px !important;
+ padding: 36px 66px;
+ }
+ .aienableddisclaimerform .ui-titlebar-hidden.ui-dialog-titlebar {
+ top: 32px;
+ right: 42px;
+ padding: 0;
+ width: 483px;
+ }
+ .aienableddisclaimerform .ui-titlebar-hidden .ui-icon-closethick {
+ top: 3px;
+ left: 3px;
+ margin:0;
+ padding: 0;
+ background-size: 20px;
+ height: 20px;
+ background-position: center;
+ width: 20px;
+ position: absolute;
+ }
+ .aienableddisclaimerform .search-disclaimer-wrapper {
+ margin-top: 40px;
+ width: 449px;
+ }
+ .aienableddisclaimerform .search-disclaimer-wrapper .h2 {
+ font-size: calc(1em + 16px);
+ line-height: 25px;
+ }
+ .aienableddisclaimerform .search-disclaimer-wrapper .search-disclaimer-text {
+ font-size: calc(1em + 2px);
+ line-height: 25px;
+ padding: 0;
+ margin-top: 32px;
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/grid_of_cards.css b/docroot/modules/custom/bos_components/modules/bos_search/css/grid_of_cards.css
new file mode 100644
index 0000000000..ac8f045517
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/grid_of_cards.css
@@ -0,0 +1,21 @@
+.grid-of-cards {
+ padding-top: 0;
+ margin-top: 48px;
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 1rem;
+}
+.goc-wrapper {
+ padding: 0;
+ width: 80%;
+ max-width: 80%;
+ margin-bottom: 72px;
+}
+.goc-title {
+ margin: 0;
+ padding: 0;
+}
+.goc-grid {
+ margin-top: 24px;
+ row-gap: 16px;
+ width: 100%;
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/modal_close.css b/docroot/modules/custom/bos_components/modules/bos_search/css/modal_close.css
new file mode 100644
index 0000000000..d7157985b5
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/modal_close.css
@@ -0,0 +1,97 @@
+.modal-close-wrapper {
+ display:none;
+ flex-direction: row;
+ font-family: 'Lora', 'Georgia', serif;
+ font-size: 16px;
+ letter-spacing: 0.5px;
+ position: sticky;
+ min-height: 72px;
+ background-color: #ffffff;
+ z-index: 10;
+ color: #58585b;
+}
+.aienabledsearchform.has-results .modal-close-wrapper {
+ display:flex;
+ background-color: #ffffff;
+}
+
+.modal-close-wrapper .ai-form-reset {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ top: 10px;
+ margin-top: 25px;
+}
+.modal-close-wrapper .ai-reset-button {
+ padding: 8px 22px 8px 16px;
+ display: block;
+ max-height: 44px;
+ line-height: calc(1em + 10px);
+ border-radius: 4px;
+ font-size: calc(1em - 2px);
+}
+.modal-close-wrapper .ai-form-reset{
+ background: rgba(40, 139, 228, 0.10);
+}
+.modal-close-wrapper .ai-form-reset:hover{
+ background: rgba(40, 139, 228, 0.20);
+}
+.modal-close-wrapper .ai-form-reset:active{
+ background: rgba(40, 139, 228, 0.40);
+}
+
+.modal-close-wrapper .ai-reset-button:after {
+ content: "Reset Search";
+ margin: 0 0 0 28px;
+ width: max-content;
+ display: block;
+}
+.modal-close-wrapper .ai-reset-button:before {
+ content: "";
+ background-image: url("../img/plus.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-position-x: left;
+ border: none;
+ position: absolute;
+ left: 0;
+ top: 10px;
+ height: 20px;
+ width: 20px;
+ margin-left: 16px;
+ margin-right: 8px;
+ background-size: 20px;
+ display: block;
+}
+.modal-close-wrapper .modal-close {
+ background-image: url("../img/modal_close.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ border: none;
+ right: 24px;
+ position: absolute;
+ height: 18px;
+ width: 18px;
+ top: 37px;
+}
+.modal-close-wrapper .ai-form-reset:hover,
+.modal-close-wrapper .modal-close:hover {
+ cursor: pointer;
+}
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+
+@media screen and (min-width: 480px) {
+
+ .modal-close-wrapper {}
+ .ai-form-reset { }
+ .ai-form-reset .ai-reset-button { }
+
+ .ai-form-reset .ai-reset-button .ai-reset-icon:before {
+ scale: 100%;
+ top: 26px;
+ margin-left: 10px;
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/quote_card.css b/docroot/modules/custom/bos_components/modules/bos_search/css/quote_card.css
new file mode 100644
index 0000000000..d286fdc544
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/quote_card.css
@@ -0,0 +1,8 @@
+.goq-quote-text {}
+.goq-quote-details {}
+.goq-quote-photo {}
+.goq-quote-photo img {}
+.goq-quote-photo a {}
+.goq-quote-person-details {}
+.goq-quote-name {}
+.goq-quote-location {}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/search_bar.css b/docroot/modules/custom/bos_components/modules/bos_search/css/search_bar.css
new file mode 100644
index 0000000000..1343baba7f
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/css/search_bar.css
@@ -0,0 +1,117 @@
+
+div.search-bar-wrapper {
+ font-family: 'Lora', 'Georgia', serif;
+ color: #091F2F; /* Charles Blue */
+ width: 100%;
+ margin: 0 auto 0 auto;
+ font-size: 16px;
+ letter-spacing: 0.5px;
+}
+div.search-bar-title {
+ margin: 0 auto;
+ width: 80%;
+}
+div.search-bar-input-wrapper {
+ position: relative;
+}
+div.search-bar-input {
+ background-color: rgba(40, 139, 228, 0.1);
+ border-radius: 8px;
+ border: 1px solid rgba(40, 139, 228, 1);
+ padding: 2px;
+ font-size: calc(1em - 2px);
+ margin: 0;
+}
+div.search-bar-input:focus,
+div.search-bar-input:focus-within,
+div.search-bar-input:hover,
+div.search-bar-input:active {
+ border: 3px solid rgba(40, 139, 228, 1);
+ padding: 0;
+}
+
+div.search-bar-submit {
+ background-position: center;
+ background-color: rgba(24, 113, 189, 0.8);
+ background-repeat: no-repeat;
+ border: 1px solid rgba(24, 113, 189, 0.5); /* Optimistic Blue 50% */
+ border-radius: 8px;
+ position: absolute;
+ right: 12px;
+ top: 12px;
+ height: 44px;
+ width: 44px;
+ font-size: inherit;
+}
+div.search-bar-submit:hover,
+div.search-bar-submit:hover:not(:disabled),
+div.search-bar-submit:active {
+ background-color: rgba(24, 113, 189, 1);
+}
+div.search-bar-microphone {
+ background-image: url("../img/microphone.svg");
+ background-position: center;
+ background-color: transparent;
+ background-repeat: no-repeat;
+ border: none;
+ border-radius: 8px;
+ position: absolute;
+ right: 72px;
+}
+div.search-bar-microphone:hover,
+div.search-bar-microphone:hover:not(:disabled),
+div.search-bar-microphone:active{
+ background-color: rgba(24, 113, 189, 0.5);
+}
+input.search-bar {
+ color: #091f2f; /* Charles Blue */
+ line-height: calc(48px * 1.2);
+ background-color: transparent;
+ border:0;
+ height: 44px;
+ margin: 8px 0 8px 0;
+ padding: 0 12px 0 24px;
+ font-size: calc(1em + 2px);
+ width: calc(100% - 70px);
+ outline: none;
+}
+.search-bar:focus-visible {
+}
+input.search-bar.searching,
+.search-bar::placeholder {
+ color: rgba(9, 31, 47, 0.5); /* Charles Blue 50% */
+ font-style: normal;
+ font-size: 1em;
+}
+div.search-bar-description {
+ text-align: center;
+ color: #555;
+ margin: 3px auto 0 auto;
+ font-size: calc(1em - 4px);
+ line-height: 14px;
+}
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+
+@media screen and (min-width: 480px) {
+ input.search-bar {
+ height: 48px;
+ }
+ div.search-bar-submit {
+ height: 48px;
+ width: 48px;
+ }
+ div.search-bar-input-wrapper {
+ width: 100%;
+ margin: 5px auto 0 auto;
+ }
+ div.search-bar-description {
+ font-size: calc(1em + -2px);
+ padding: 8px 0 8px 0;
+ }
+ div.search-bar-input {
+ font-size: calc(1em + 2px);
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_1100.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_1100.gif
new file mode 100644
index 0000000000..94811649c4
Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_1100.gif differ
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_550.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_550.gif
new file mode 100644
index 0000000000..1926509d72
Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_550.gif differ
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_1000.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_1000.gif
new file mode 100644
index 0000000000..da42f40a7f
Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_1000.gif differ
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_550.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_550.gif
new file mode 100644
index 0000000000..64c8069555
Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_550.gif differ
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/microphone.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/microphone.svg
new file mode 100644
index 0000000000..9d8da0e83c
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/microphone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/modal_close.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/modal_close.svg
new file mode 100644
index 0000000000..6cb3723a31
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/modal_close.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/plus.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/plus.svg
new file mode 100644
index 0000000000..5ef951763c
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/search.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/search.svg
new file mode 100644
index 0000000000..a6343b0ef9
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/search.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/speaker.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/speaker.svg
new file mode 100644
index 0000000000..554a8aeab7
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/speaker.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/thumbdown.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbdown.svg
new file mode 100644
index 0000000000..1cdc4db574
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbdown.svg
@@ -0,0 +1,4 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/thumbup.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbup.svg
new file mode 100644
index 0000000000..1f881305d7
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbup.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle.svg
new file mode 100644
index 0000000000..71bdf1a22f
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle_white.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle_white.svg
new file mode 100644
index 0000000000..7909b174a8
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle_white.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/x.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/x.svg
new file mode 100644
index 0000000000..16a4157cc8
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/img/x.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/includes/aisearch_form_theme.inc b/docroot/modules/custom/bos_components/modules/bos_search/includes/aisearch_form_theme.inc
new file mode 100644
index 0000000000..6357ac88c6
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/includes/aisearch_form_theme.inc
@@ -0,0 +1,222 @@
+ $name) {
+
+ $templates = AiSearch::getFormTemplates($theme);
+ foreach($templates as $template => $template_name) {
+ $idx_file = str_replace(["_", " "], "-", $template);
+ $idx = str_replace(["-", " "], "_", $template);
+ if ($idx == "results") {
+ $existing["{$idx}__{$theme}"] = [
+ 'template' => "presets/$theme/$idx_file",
+ 'variables' => [
+ "response" => NULL,
+ "items" => NULL,
+ "metadata" => NULL,
+ "references" => NULL,
+ "citations" => NULL,
+ "content" => NULL,
+ "id" => NULL,
+ "feedback" => NULL,
+ ],
+ ];
+ }
+ else {
+ $existing["{$idx}__{$theme}"] = [
+ 'template' => "presets/$theme/$idx_file",
+ 'base_hook' => $idx,
+ 'render element' => 'children',
+ ];
+ }
+ }
+
+ }
+
+}
+
+function _bos_search_snippet_theme(array &$existing):void {
+ $existing['modal_close'] = [
+ 'template' => 'snippets/modal-close',
+ 'variables' => [
+ ],
+ ];
+ $existing["aisearch_feedback"] = [
+ 'template' => 'snippets/aisearch-feedback',
+ 'variables' => [
+ 'thumbsup' => FALSE,
+ 'thumbsdown' => FALSE,
+ ],
+ ];
+}
+
+/**
+ * Implements hook_preprocess().
+ */
+function bos_search_preprocess(&$variables, $hook, $info) {
+
+ if (!AiSearch::isBosSearchThemed()) {
+ return;
+ }
+
+ switch ($hook) {
+
+ case "block":
+ template_preprocess_block($variables);
+ if ($variables["elements"]["#plugin_id"] == "Ai-enabled-search-button") {
+ $template = explode("/" ,$info["template"]);
+ $theme = $template[1];
+ $variables["content"]['#preset_theme'] = $theme;
+ }
+ break;
+
+ case "form":
+ if ($variables["element"]["#form_id"] == "bos_search_AISearchForm") {
+ template_preprocess_form($variables);
+
+ // Add some extra configuration information
+ $config = \Drupal::request()->query->all();
+ if (empty($config)){
+ $config["preset"] = $variables["element"]["AiSearchForm"]["content"]["preset"]["#value"];
+ $config["display"] = "block";
+ }
+ if ($variables["configuration"] = $config) {
+ $variables["preset"] = AiSearch::getPresetValues($variables["configuration"]["preset"] ?? 'default') ?? [];
+
+ // If required, add in the modal close header .
+ if (($variables["configuration"]["display"] ?? "block") == "modal"
+ || $variables["preset"]["searchform"]["searchbar"]["allow_reset"]) {
+ $variables["form_header"] = [
+ '#type' => 'modal_close',
+ '#theme' => 'modal_close',
+ ];
+ }
+
+ // Include any custom styles and scripts.
+ $custom_theme_path = "/modules/custom/bos_components/modules/bos_search/templates/presets/{$variables['preset']['searchform']['theme']}";
+ $variables["#attached"]["drupalSettings"]["bos_search"] = [
+ 'dynamic_script' => "$custom_theme_path/js/preset.js",
+ 'dynamic_style' => "$custom_theme_path/css/preset.css",
+ 'waiting_text' => $variables["preset"]["searchform"]['searchbar']["waiting_text"],
+ ];
+ // Include script to load custom scripts and styles.
+ $variables['#attached']['library'][] = 'bos_search/dynamic-loader';
+ }
+ }
+ break;
+
+ case "fieldset":
+ template_preprocess_fieldset($variables);
+ break;
+
+
+ case "container":
+ template_preprocess_container($variables);
+ break;
+
+ case "input":
+ _bos_search_preprocess_input($variables, $hook);
+ break;
+
+ case "textarea":
+ template_preprocess_textarea($variables);
+ $variables["attributes"]["class"][] = "txt-f";
+ break;
+
+ case "form_element":
+ template_preprocess_form_element($variables);
+ break;
+
+ case "form_element_label":
+ template_preprocess_form_element_label($variables);
+ break;
+
+ default:
+ break;
+ }
+}
+
+function _bos_search_preprocess_input(&$variables, $hook):void {
+
+ template_preprocess_input($variables);
+ $variables["attributes"] += $variables["element"]["#attributes"];
+ $variables["children"] = $variables['element'];
+
+}
+
+/**
+ * Implements hook_theme_suggestions_alter().
+ */
+function _search_form_suggestions(array &$suggestions, array &$variables, $hook):void {
+
+ if (!empty($variables["element"])) {
+ // Get the form theme being used by the active preset, or else use 'default'.
+ $node = \Drupal::request()->attributes->get('node', NULL);
+ $preset = AiSearch::getPreset(node: $node);
+ $form_theme = AiSearch::getPresetValues($preset)["searchform"]["theme"];
+
+ switch ($hook) {
+ case "form":
+ if (isset($variables["element"]["#errors"])) {
+ return;
+ }
+ if ($variables["element"]["#form_id"] == 'bos_search_AISearchForm') {
+ $suggestions[] = "form__$form_theme";
+ }
+ break;
+ case "form_element":
+ case "form_element_label":
+ if (in_array("AiSearchForm", $variables["element"]["#array_parents"] ?? [])) {
+ $suggestions[] = "{$hook}__$form_theme";
+ }
+ break;
+ default:
+ if (in_array("AiSearchForm", $variables["element"]["#array_parents"] ?? [])) {
+ if (in_array($variables["element"]["#type"], [
+ "hidden",
+ "button",
+ "submit",
+ ])) {
+ $suggestions[] = "input__$form_theme";
+ }
+ else {
+ $suggestions[] = $variables["element"]["#type"] . "__$form_theme";
+ }
+ }
+ break;
+ }
+ }
+
+ // Adds suggestions to allow themeing the AI Search Blocks.
+ // The block will use a template based on the preset being used.
+ if ($hook == "block") {
+
+ if ($variables["elements"]["#plugin_id"] == "Ai-enabled-search-button") {
+ // Get the theme from the preset - or use 'default'
+ if ($preset = $variables["elements"]["#configuration"]["aisearch_config_preset"] ?? FALSE) {
+ $form_theme = AiSearch::getPresetValues($preset)["searchform"]["theme"] ?? $form_theme;
+ }
+ $suggestions[] = "block__button__{$form_theme}";
+ }
+ elseif ($variables["elements"]["#plugin_id"] == "Ai-enabled-search-form") {
+ $preset = AiSearch::getPreset();
+ $form_theme = AiSearch::getPresetValues($preset)["searchform"]["theme"];
+ $suggestions[] = "block__form__{$form_theme}";
+ }
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/includes/bos_theme_theme.inc b/docroot/modules/custom/bos_components/modules/bos_search/includes/bos_theme_theme.inc
new file mode 100644
index 0000000000..535229a61f
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/includes/bos_theme_theme.inc
@@ -0,0 +1,104 @@
+ 'components/quote-card',
+ 'variables' => [
+ "content" => "",
+ "person" => "",
+ "show_quotes" => "",
+ "picture" => "",
+ "location" => "",
+ "attributes" => [],
+ ],
+ ];
+ $existing['card'] = [
+ 'template' => 'components/card',
+ 'variables' => [
+ "attributes" => [],
+ "link" => "",
+ "image" => "",
+ "title" => "",
+ "title_attributes" => [],
+ "subtitle" => "",
+ "subtitle_attributes" => [],
+ "content" => "",
+ "content_attributes" => [],
+ "parent" => "",
+ "parent_array" => [],
+ ],
+ ];
+ $existing['grid_of_cards'] = [
+ 'template' => 'components/grid-of-cards',
+ 'variables' => [
+ "attributes" => [],
+ "title" => "",
+ "title_attributes" => [],
+ "cards" => [],
+ "type_array" => [],
+ "type" => '',
+ ],
+ "render element" => "cards",
+ ];
+ $existing['search_bar'] = [
+ 'template' => 'components/search-bar',
+ 'variables' => [
+ "wrapper_attributes" => [],
+ "attributes" => [],
+ "title" => "",
+ "title_attributes" => [],
+ "icon" => "/" . \Drupal::moduleHandler()->getModule("bos_search")->getPath() . "/img/search.svg",
+ "value" => "",
+ "default_value" => "",
+ "description" => '',
+ "description_display" => "after",
+ "audio_search_input" => FALSE
+ ],
+ ];
+}
+
+/**
+ * Implements hook_preprocess_HOOK().
+ */
+function bos_search_preprocess_grid_of_cards(&$variables):void {
+
+ // Give the grid of cardas a unique ID, this is useful for anchoring.
+ $variables["attributes"]["id"] = $variables["attributes"]["data-drupal-selector"] ?? "grid_" . rand(100000,999999);
+
+ // Give each card a unique ID within this grid.
+ foreach($variables['cards'] as $key => &$card) {
+ $variables["type_array"][] = $card["#type"];
+ $card["#attributes"]["id"] = "{$variables["attributes"]["id"]}_card_{$key}";
+ $card["#attributes"]["class"][] = "card_{$key}";
+ $card["#parent"] = $variables["attributes"]["id"];
+ $card["#parent_array"][] = $variables["attributes"]["id"];
+ }
+
+}
+
+function _bos_search_preprocess_search_bar(&$variables):void {
+ $variables['value'] = $variables['value'] ?: $variables['default_value'];
+ $variables['wrapper_attributes'] = new \Drupal\Core\Template\Attribute();
+ $variables["attributes"]["type"] = "text";
+ $variables["attributes"]["id"] = $variables["attributes"]["aria-describedby"] ?? "edit-search-bar";
+ $variables["attributes"]["name"] = str_replace("edit-", "", $variables["attributes"]["data-drupal-selector"] ?? "search-bar");
+ $variables["title_attributes"]["for"] = $variables["attributes"]["id"];
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/ai_feedback.js b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_feedback.js
new file mode 100644
index 0000000000..a11c7df6dd
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_feedback.js
@@ -0,0 +1,73 @@
+(function ($, Drupal, once) {
+ Drupal.behaviors.ai_search_feedback = {
+ attach: function (context, settings) {
+ once('feedbackForm', '.ai-feedback-wrapper', context).forEach(
+ function (element) {
+ $(document).on("ajaxComplete", function (event, xhr, settings) {
+ if (xhr.statusText.toString() === 'success') {
+ var thisdialog = $('.feedback-dialog');
+ if (settings.url.toString().startsWith("/form/ai-search-feedback") && thisdialog.length > 0) {
+ if (thisdialog.find(".text-count-message").length > 0) {
+ var more = thisdialog.find('textarea[name=tell_us_more]');
+ more.on("keyup", function(element){textarea_counter(element.target, thisdialog);})
+ var submission = $(".aienabledsearchform").find("#search-conversation-wrapper");
+ var question = submission.find(".search-request").last().html();
+ var summary = submission.find(".search-response-text").last().html();
+ if (question) {
+ thisdialog.find('.search-question').val(question);
+ }
+ if (summary) {
+ thisdialog.find('.search-summary').val(summary);
+ }
+ }
+ else {
+ var message = thisdialog.text().trim("\n");
+ message = message.replace("Close","").trim(' ');
+ $(".aienabledsearchform .ai-feedback-confirm").last().text(message).show();
+ $(".aienabledsearchform .ai-feedback-buttons").last().hide();
+ var searchform = $('.aienabledsearchform');
+ var thumbs = $('.ai-feedback-wrapper').last();
+ move_div_to_middle(searchform, thumbs);
+ $("#drupal-modal").dialog("close");
+ }
+ }
+ }
+ });
+ }
+ );
+ }
+ };
+
+ var textarea_counter = function (element, thisdialog) {
+ var textbox = $(element).val();
+ var count = parseInt(textbox.length);
+ if (!count) {
+ thisdialog.find('.text-count-message').text('200 characters allowed');
+ }
+ else {
+ thisdialog.find('.text-count-message').text((200 - count) + ' characters remaining');
+ }
+ };
+
+ var move_div_to_middle = function(searchform, div) {
+ if ($(".search-response-wrapper").length) {
+ var offsetHeight = ((div.offset().top) - (searchform.offset().top) - ($(window).height() / 3));
+ var scroll_layer = $("html, body");
+ if (searchform.hasClass("aisearch-modal-form")) {
+ scroll_layer = searchform;
+ offsetHeight = ((div.offset().top) - (searchform.offset().top) - window.height() );
+ }
+ scroll_layer.animate({
+ scrollTop: offsetHeight,
+ }, 'fast');
+ }
+ }
+
+ var add_click_to_feedback = function (element) {
+ $(element).on("click", function (event) {
+ even.peventDefault();
+ var form = $(".ai-feedback-wrapper");
+ });
+ }
+
+})(jQuery, Drupal, once);
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/ai_searchbutton.js b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_searchbutton.js
new file mode 100644
index 0000000000..1a12f2eaf1
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_searchbutton.js
@@ -0,0 +1,6 @@
+(function ($, Drupal, once) {
+ Drupal.behaviors.ai_search_button = {
+ attach: function (context, settings) {
+ }
+ }
+})(jQuery, Drupal, once);
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/bos_search.js b/docroot/modules/custom/bos_components/modules/bos_search/js/bos_search.js
new file mode 100644
index 0000000000..286e09db6f
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/js/bos_search.js
@@ -0,0 +1,226 @@
+(function ($, Drupal, once) {
+ Drupal.behaviors.aiSearch = {
+ attach: function (context, settings) {
+ var new_height = 0;
+ once('loadExample', '.bos-search-aisearchform .card', context).forEach(
+ function(element){
+ if (!new_height) {
+ new_height = find_max_height($(element.parentElement).children());
+ }
+ $(element).css({"min-height": new_height});
+ $(element).on("click", function(event) {
+ $(".bos-search-aisearchform .search-bar").val($(element).find(".card-content").text());
+ submit_form();
+ });
+ }
+ );
+ once('aiSearch', '.bos-search-aisearchform #search-bar-submit', context).forEach(
+ function (element) {
+ $(element).click(function (event) {
+ event.preventDefault();
+ submit_form();
+ });
+ }
+ );
+ once('aiSearch2', '.bos-search-aisearchform .search-bar', context).forEach(
+ function (element) {
+ $(element).keyup(function (event) {
+ if(event.originalEvent.key === "Enter") {
+ $('.bos-search-aisearchform #search-bar-submit').click();
+ }
+ });
+ }
+ );
+ once('ajaxMonitor', '.aienabledsearchform', context).forEach(
+ function(element){
+ $(document).on("ajaxError", function(event, xhr, settings, thrownError) {
+ event.preventDefault();
+ var searchform = $('.aienabledsearchform');
+ searchform.find(".search-bar")
+ .removeClass("searching")
+ .removeAttr('disabled')
+ .val('')
+ .focus();
+
+ if (drupalSettings.user.uid !== 0) {
+ console.log('Custom AJAX Error Handler: An error occurred.');
+ console.log('Error details:', xhr.responseText);
+ }
+ });
+ $(document).on("ajaxComplete", function(event, xhr, settings) {
+ var searchform = $('.aienabledsearchform');
+ var this_request = searchform.find(".search-request").last();
+ var this_response = searchform.find(".search-response-text").last();
+ var this_citations = searchform.find(".search-citations-wrapper").last();
+
+ if (xhr.statusText.toString() === 'success') {
+ toggle_welcome_block(xhr.responseJSON);
+ limit_citations_height(this_response, this_citations);
+ toggle_citations_show_more(this_response, this_citations);
+ }
+ searchform.find('.search-request-progress-wrapper').remove();
+ move_div_to_top(searchform, this_request);
+ searchform.find(".search-bar")
+ .removeAttr('disabled')
+ .removeClass("searching")
+ .val("")
+ .focus();
+ });
+ }
+ );
+
+ },
+
+ };
+
+ var submit_form = function () {
+
+ var searchform = $('.aienabledsearchform');
+
+ if (validate_form(searchform)) {
+
+ var welcome_block = searchform.find('#edit-welcome');
+ // var search_bar=searchform.find("#search-bar-submit");
+
+ add_request_bubble(searchform);
+ var this_request = searchform.find(".search-request-wrapper").last();
+ move_div_to_top(searchform, this_request);
+
+ if (welcome_block.length > 0) {
+ collapse_welcome_block(searchform);
+ }
+
+ searchform.find("input.form-submit").mousedown();
+
+ searchform.find('.search-bar')
+ .attr("disabled",'')
+ .addClass('searching')
+ .val(drupalSettings.bos_search.waiting_text)
+ .focus();
+
+ }
+ else {
+ searchform.find('.search-bar-input input').focus();
+ }
+
+ }
+
+ var validate_form = function (searchform) {
+ if (searchform.find('.search-bar').val()) {
+ return true;
+ }
+ return false;
+ }
+
+ var add_request_bubble = function(searchform) {
+ var request_text = searchform.find('.search-bar').val();
+ searchform.find('#search-conversation-wrapper').append("" +
+ "
" +
+ "
" + request_text + "
" +
+ "
" +
+ "
" +
+ "
" +
+ "
");
+ }
+
+ var collapse_welcome_block = function(searchform) {
+ searchform.find('#edit-welcome').slideUp('slow', function() {
+ searchform.animate({
+ scrollTop: searchform.prop('scrollHeight')
+ }, 'fast');
+ });
+ }
+
+ var toggle_welcome_block = function (responses) {
+
+ var mainAction;
+
+ $(responses).each(function (index, element) {
+ if (element.command === 'insert' && typeof mainAction === 'undefined') {
+ mainAction = element;
+ }
+ });
+
+ if (mainAction && mainAction.command === 'insert') {
+ // Looks like the ajax command succeeded.
+ if (mainAction.dialogOptions) {
+ // This is a modal form.
+ var classes = mainAction.dialogOptions.classes["ui-dialog"];
+ if (classes && (!classes.match("aienableddisclaimerform") && !classes.match("feedback-dialog"))) {
+ // this is the main search form.
+ $('.bos-search-aisearchform').addClass('no-welcome');
+ }
+ }
+ else if (mainAction.selector !== '#drupal-modal') {
+ // Not a modal form, and not using the modal selector therefore we are appending results.
+ $('.bos-search-aisearchform').addClass('no-welcome');
+ }
+ }
+
+ }
+
+ var limit_citations_height = function(response, citations) {
+ var drawer = citations.find(".search-citations-drawer");
+ while (response && drawer && response.height() < (drawer.height() - 40)) {
+ var elem = drawer.find('.search-citation:not(".hidden"):not(".search-citation-more")').last()
+ if (elem.length === 0){
+ return;
+ }
+ elem.addClass("hidden").css({"display":"none"});
+ drawer.addClass("show-more");
+ }
+ }
+
+ var toggle_citations_show_more = function(response, citations) {
+ var drawer = citations.find(".search-citations-drawer");
+ if (citations.length && drawer.hasClass("show-more")) {
+ drawer
+ .find('.search-citation-more')
+ .on("click", function(e){
+ e.preventDefault();
+ drawer.removeClass("show-more")
+ .css({"overflow-y": "scroll"})
+ .find(".search-citation.hidden")
+ .removeClass("hidden")
+ .css({"display": "block"});
+ });
+ drawer.css({"max-height": response.height() + "px"});
+ drawer.find("dr-c").css({"min-height": response.height() + "px"});
+ }
+ }
+
+ var move_div_to_top = function(searchform, div) {
+ if ($(".search-response-wrapper").length) {
+
+ var site_banner = $('.site-banner');
+ site_banner = site_banner.length ? site_banner.height() : 0;
+
+ var offsetHeight = ((div.offset().top) - (searchform.offset().top) - site_banner);
+ var scroll_layer = $("html, body");
+
+ if (searchform.hasClass("aisearch-modal-form")) {
+ scroll_layer = searchform;
+ offsetHeight = ((div.offset().top) - (searchform.offset().top) - site_banner);
+ }
+
+ scroll_layer.animate({
+ scrollTop: offsetHeight,
+ }, 'fast');
+
+ }
+ }
+
+ var find_max_height = function(elements) {
+ var max_example_height = 0;
+ elements.each(function(idx, element) {
+ if (max_example_height < $(element).outerHeight()) {
+ max_example_height = $(element).outerHeight();
+ }
+ });
+ return max_example_height + "px";
+ }
+
+})(jQuery, Drupal, once);
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/disclaimer.js b/docroot/modules/custom/bos_components/modules/bos_search/js/disclaimer.js
new file mode 100644
index 0000000000..a61a82f993
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/js/disclaimer.js
@@ -0,0 +1,40 @@
+(function ($, Drupal, once) {
+ Drupal.behaviors.searchDisclaimer = {
+ attach: function (context, settings) {
+ $(document).ready(function(){
+ var disclaimerform = $('.aisearch-disclaimer-form .ui-button');
+ if (disclaimerform.length && !disclaimerform.attr('disclaimer-once')) {
+ disclaimerform.click(function (event) {
+ event.preventDefault();
+ Drupal.dialog("#drupal-modal").close();
+ });
+ disclaimerform.attr({ 'disclaimer-once': true })
+ }
+ });
+
+ const element = $('.aienabledsearchform', context);
+
+ // Only display the disclaimer once per form display
+ if (element.length > 0 && !element.attr('data-once-searchDisclaimer')) {
+ element.attr('data-once-searchDisclaimer', true);
+
+ // Callback to create the disclaimer.
+ if (settings.disclaimerForm.triggerDisclaimerModal) {
+ Drupal.ajax({
+ url: settings.disclaimerForm.openModal,
+ }).execute();
+
+ $(document).on("ajaxComplete", function(event, xhr, settings) {
+ // Fires when the disclaimer form is returned by ajax
+ $('.aienableddisclaimerform .btn-submit').click(function (event) {
+ event.preventDefault();
+ Drupal.dialog("#drupal-modal").close();
+ });
+ });
+
+ }
+
+ }
+ }
+ };
+})(jQuery, Drupal, once);
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/dynamic_loader.js b/docroot/modules/custom/bos_components/modules/bos_search/js/dynamic_loader.js
new file mode 100644
index 0000000000..a2c23a170e
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/js/dynamic_loader.js
@@ -0,0 +1,48 @@
+(function ($, Drupal, drupalSettings) {
+ Drupal.behaviors.bosSearchDynamicLoader = {
+ attach: function (context, settings) {
+
+ var scriptPath = drupalSettings.bos_search.dynamic_script;
+ var cssPath = drupalSettings.bos_search.dynamic_style;
+
+ // Function to load a JavaScript file
+ function loadScript(url) {
+ var script = document.createElement('script');
+ script.src = url;
+ document.head.appendChild(script);
+ }
+
+ // Function to load a CSS file
+ function loadCSS(url) {
+ var link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = url;
+ document.head.appendChild(link);
+ }
+
+ // Load resources if they are defined
+ const element = $('.aienabledsearchbutton', context);
+ if (element.length > 0 && !element.attr('data-once-loadPresetJS')) {
+ element.attr('data-once-loadPresetJS', true);
+ if (scriptPath) {
+ loadScript(scriptPath);
+ }
+ if (cssPath) {
+ loadCSS(cssPath);
+ }
+ }
+ else {
+ const element = $('.aienabledsearchform', context);
+ if (element.length > 0 && !element.attr('data-once-loadPresetJS')) {
+ element.attr('data-once-loadPresetJS', true);
+ if (scriptPath) {
+ loadScript(scriptPath);
+ }
+ if (cssPath) {
+ loadCSS(cssPath);
+ }
+ }
+ }
+ }
+ };
+})(jQuery, Drupal, drupalSettings);
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/modal_close.js b/docroot/modules/custom/bos_components/modules/bos_search/js/modal_close.js
new file mode 100644
index 0000000000..cf81243af9
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/js/modal_close.js
@@ -0,0 +1,44 @@
+(function ($, Drupal, once) {
+ Drupal.behaviors.modal_close = {
+ attach: function (context, settings) {
+ once('modal_close', '#drupal-modal .modal-close', context).forEach(
+ function (element) {
+ $(element).click(function (event) {
+ Drupal.dialog("#drupal-modal").close();
+ });
+ }
+ );
+ once('modal_reset', '.aienabledsearchform .ai-form-reset', context).forEach(
+ function (element) {
+ $(element).click(function (event) {
+ // $('.bos-search-aisearchform').removeClass('no-welcome');
+ var searchform = $('.aienabledsearchform');
+ searchform.find('[name=session_id]').val("");
+ searchform.find('#search-conversation-wrapper')
+ .fadeOut('fast', function(){
+ searchform.find('#search-conversation-wrapper').empty().show();
+ searchform.find('#edit-welcome').slideDown('fast');
+ searchform.removeClass("has-results");
+ searchform.find("input.search-bar").removeAttr('disabled').focus();
+ });
+ });
+ }
+ );
+ once('resetAi', '.aienabledsearchform', context).forEach(
+ function(element){
+ $(document).on("ajaxComplete", function(event, xhr, settings) {
+ var searchform = $('.aienabledsearchform');
+ if (xhr.statusText.toString() === 'success' &&
+ drupalSettings.has_results) {
+ if (!searchform.hasClass('has-results')) {
+ searchform
+ .addClass('has-results')
+ }
+ }
+ });
+ }
+ );
+
+ },
+ };
+})(jQuery, Drupal, once);
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/readme.md b/docroot/modules/custom/bos_components/modules/bos_search/readme.md
new file mode 100644
index 0000000000..54b27f2aad
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/readme.md
@@ -0,0 +1,2 @@
+lando drush pmu bos_search bos_gc_aisearch_plugin bos_aws_services
+lando drush en bos_search bos_gc_aisearch_plugin bos_aws_services
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearch.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearch.php
new file mode 100644
index 0000000000..3d51b067a0
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearch.php
@@ -0,0 +1,270 @@
+hasValue("preset") ?: FALSE) {
+ return $form_state->getValue("preset");
+ }
+
+ // Get the preset from the request object.
+ $request = \Drupal::request();
+ if ($request->query->has('preset')) {
+ return $request->query->get('preset');
+ }
+
+ // If node is present.
+ if ($node) {
+ $preset = $_SESSION['bos_search']['block_preset'][$node->id()] ?: FALSE;
+ if ($preset) {
+ return $preset;
+ }
+ else {
+ $preset = self::getNodeBlock(['aienabledsearchbutton', 'aienabledsearchform']);
+ if(is_string($preset)) {
+ $_SESSION['bos_search']['block_preset'][$node->id()] = $preset;
+ return $preset;
+ }
+ }
+ }
+
+ // Return the first preset as a default.
+ return array_key_first(self::getPresets());
+
+ }
+
+ /**
+ * Fetch the preset (set on Search Config Form)
+ *
+ * @param string $preset_name
+ *
+ * @return array
+ */
+ public static function getPresetValues(string $preset_name = ""): array {
+ if ($preset_name == "") {
+ $preset_name = self::getPreset();
+ }
+ $config = \Drupal::config("bos_search.settings")->get("presets");
+ if (empty($preset_name)) {
+ return [];
+ }
+ else {
+ return $config[$preset_name] ?? [];
+ }
+ }
+
+ /**
+ * Get an Assoc Array with all presets listed.
+ * This format is suitable for options in select form objects.
+ *
+ * @return array
+ */
+ public static function getPresets(): array {
+ $config = \Drupal::config("bos_search.settings")->get("presets") ?? [];
+ $output = [];
+ foreach ($config as $cid => $preset) {
+ $output[$cid] = $preset["name"];
+ }
+ return $output;
+ }
+
+ /**
+ * Creates a new string from a string.
+ * The new string can be used as a valid drupal machine id.
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ public static function machineName(string $name):string {
+ return strtolower(preg_replace('/[^a-zA-Z0-9_]+/', '_', $name));
+ }
+
+ /**
+ * Cleans up a string.
+ *
+ * @param $string string the string to be cleaned
+ *
+ * @return string the cleaned string
+ */
+ public static function sanitize(string $string): string {
+ // TODO: Do we want to add profanity filters or other forms of sanitation here?
+ return (trim($string));
+ }
+
+ /**
+ * Scans the Templates folder and gets a list of implemented themes (subfolders)
+ * for the main search form.
+ *
+ * @return array
+ */
+ public static function getFormThemes(): array {
+ $folders = glob(\Drupal::service("extension.list.module")->getPath('bos_search') . "/templates/presets/*", GLOB_ONLYDIR);
+ $themes = [];
+ foreach($folders as $folder) {
+ $folder = basename($folder);
+ $themes[$folder] = ucwords(str_replace(["_", "-"], " ", $folder));
+ }
+ return $themes;
+ }
+
+ /**
+ * Scans the provided folder's 'presets' subfolder and gets a list of
+ * implemented templates to be used for the overall search theme for the
+ * main search form.
+ *
+ * The array has an index with the filename stripped of "html.twig" extension
+ * with "-" replacing underscores in the filename.
+ * The array values are a generated human-readable name for the filename by
+ * replacing all underscores spaces.
+ *
+ * @param string $theme The folder to scan
+ *
+ * @return array an assoc array of templates.
+ */
+ public static function getFormTemplates(string $theme): array {
+ $files = glob(\Drupal::service("extension.list.module")->getPath('bos_search') . "/templates/presets/{$theme}/*.html.twig");
+ $templates = [];
+ foreach($files as $file) {
+ $twig = basename($file);
+ $template = str_replace(".html.twig", "", $twig);
+ $templates[$template] = ucwords(str_replace(["_", "-"], " ", $template));
+ }
+ return $templates;
+ }
+
+ public static function isBosSearchThemed(): bool {
+
+ // Is this the disclaimer form?
+ if (\Drupal::request()->attributes->get("_route") == "bos_search.open_DisclaimerForm") {
+ return TRUE;
+ }
+
+ // Is this the AISearch form?
+ if (\Drupal::request()->attributes->get("_route") == "bos_search.open_AISearchForm") {
+ return TRUE;
+ }
+
+ // Is this the AISearch Config form?
+ if (\Drupal::request()->attributes->get("_route") == "bos_search.AiSearchConfigForm") {
+ return TRUE;
+ }
+
+ // If this is a node, check if the node has a block displayed within it.
+ if (!empty(\Drupal::request()->attributes->get("node"))) {
+ return self::hasNodeBlock(['aienabledsearchbutton', 'aienabledsearchform']);
+ }
+
+ // Don't appear to need the bos_search theme, return false.
+ return FALSE;
+ }
+
+ /**
+ * Report if the current node will display any blocks which have been created
+ * and placed based on the supplied $targetblock definitions.
+ *
+ * @param array $targetblocks
+ *
+ * @return bool
+ *
+ */
+ public static function hasNodeBlock(array $targetblocks) {
+ return !self::getNodeBlock($targetblocks) === FALSE;
+ }
+
+ /**
+ * Determine if the current node will show any blocks which implement any of
+ * the $targetblock defintions.
+ * If so, return the blocks preset if it has one, or else TRUE. If not return FALSE.
+ *
+ * @param array $targetblocks
+ *
+ * @return bool | string FALSE in not blocks found, or else the blocks preset if it has one, or else TRUE.
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ public static function getNodeBlock(array $targetblocks) {
+
+ // First find all the blocks created and placed using the block templates.
+ $blocks = \Drupal::entityTypeManager()->getStorage('block')->getQuery()
+ ->accessCheck(TRUE);
+ $or_group = $blocks->orConditionGroup();
+ foreach($targetblocks as $targetblock) {
+ $or_group = $or_group->condition('id', $targetblock, 'CONTAINS');
+ }
+ $blocks->condition($or_group);
+ $blocks = $blocks->execute();
+
+ // Now see if the block is configured to display on this node.
+ foreach($blocks as $blockname) {
+ $block = \Drupal::entityTypeManager()
+ ->getStorage('block')
+ ->load($blockname);
+ foreach ($block->getVisibilityConditions() as $condition) {
+ if ($condition->evaluate()) {
+ // Soon as you find a matching condition return.
+ $settings = $block->get("settings") ?: [];
+ if (!empty($settings["aisearch_config_preset"])) {
+ return $settings["aisearch_config_preset"];
+ }
+ return TRUE;
+ }
+ }
+ }
+
+ return FALSE;
+
+ }
+
+
+ /**
+ * Sets a custom session cookie.
+ *
+ * @param string $key
+ * The key used to store the value in the session.
+ * @param string|bool|array $value
+ * The value to store in the session, which can be a string, boolean, or array. Defaults to TRUE.
+ * NOTE: Bool values are coerced into an integer (0=false, 1=true)
+ *
+ * @return void
+ * Does not return any value.
+ */
+ public static function setSessionCookie(string $key, string|bool|array $value = TRUE):void {
+ // Set a custom session cookie.
+ if (session_status() == PHP_SESSION_NONE) {
+ session_start();
+ }
+ if (is_array($value)) {
+ $value = serialize($value);
+ }
+ $_SESSION[$key] = base64_encode($value);
+ }
+
+ /**
+ * Retrieves a custom session cookie.
+ *
+ * @param string $key
+ * The key of the session cookie to retrieve.
+ *
+ * @return string|array
+ * The decoded session cookie (bools converted to int), or FALSE if not set.
+ */
+ public static function getSessionCookie(string $key): string|array {
+ // Set a custom session cookie.
+ if (session_status() == PHP_SESSION_NONE) {
+ session_start();
+ }
+ if (empty($_SESSION['shown_search_disclaimer'])) {
+ return FALSE;
+ }
+ // return FALSE;
+ return base64_decode($_SESSION['shown_search_disclaimer']);
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchBase.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchBase.php
new file mode 100644
index 0000000000..462094c739
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchBase.php
@@ -0,0 +1,92 @@
+pluginDefinition['service'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getService(): GcAgentBuilderInterface|GcServiceInterface {
+ if (empty($this->service)) {
+ $serviceid = $this->getServiceId();
+ $this->service = \Drupal::getContainer()->get($serviceid);
+ }
+ return $this->service;
+ }
+
+ public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->getService();
+ }
+
+ /**
+ * Reformats the metadata array into a better format for rendering in twig.
+ *
+ * @param array $metadata
+ * @param array $map
+ * @param array $exclude_elem
+ *
+ * @return void
+ */
+ protected function flattenMetadata(array &$metadata, array $map = [], array $exclude_elem = []):array {
+
+ foreach ($metadata as $key => &$elem) {
+ if (is_array($elem)) {
+ $elem = $this->flatten_md($elem, $map, $exclude_elem);
+ }
+ else {
+ $key = ucwords(str_replace("_", " ", $key));
+ $metadata[$key] = $elem;
+ }
+ }
+ return $metadata;
+
+ }
+
+ private function flatten_md(array $elements, array $map = [], array $exclude_elem = [], string $prefix = ''):?array {
+
+ $output = [];
+
+ foreach ($elements as $key => $value) {
+
+ if ($value !== NULL) {
+
+ $key = ($map[$key] ?? $key);
+ $title = empty($prefix) ? $key : "$prefix.$key";
+
+ if (!in_array($title, $exclude_elem)) {
+
+ if (is_array($value)) {
+ $output = array_merge($output, $this->flatten_md($value, $map, $exclude_elem, $title) ?: []);
+ }
+ else {
+ $metatitle = str_replace(" ", "", ucwords(str_replace("_", " ", $title)));
+ $output[$title] = [
+ "key" => $metatitle,
+ "value" => $value,
+ ];
+ }
+ }
+ }
+ }
+
+ return (empty($output) ? NULL : $output);
+
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchFormCallbacks.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchFormCallbacks.php
new file mode 100644
index 0000000000..40e85ea1f7
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchFormCallbacks.php
@@ -0,0 +1,89 @@
+entityTypeManager = $entity_type_manager;
+ $this->form_builder = $form_builder;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function trustedCallbacks() {
+ return ['renderSearchForm'];
+ }
+
+ /**
+ * Lazy builder callback for switch-back link.
+ *
+ * @return array|string
+ * Render array or an emty string.
+ */
+ public function renderSearchForm(?string $preset = NULL) { //(string $title = "", ?string $preset = NULL) {
+
+ $form = $this->form_builder->getForm('Drupal\bos_search\Form\AiSearchForm', $preset);
+
+ // Enable the disclaimer if required by preset.
+// $preset = $form["AiSearchForm"]["content"]["preset"]["#value"] ?: $preset;
+ $config = AiSearch::getPresetValues($preset);
+
+ if ($config && $config["searchform"]['disclaimer']['enabled']) {
+
+ // Check if disclaimer should be shown.
+ if (($config["searchform"]['disclaimer']['show_once'] && !AiSearch::getSessionCookie('shown_search_disclaimer'))
+ || !$config["searchform"]['disclaimer']['show_once']) {
+
+ // Add in the js to show the modal, plus drupalSettings it needs.
+ $form['#attached']['library'][] = 'bos_search/disclaimer';
+ $form['#attached']['drupalSettings']['disclaimerForm'] = [
+ 'openModal' => Url::fromRoute('bos_search.open_DisclaimerForm')
+ ->toString(),
+ 'triggerDisclaimerModal' => TRUE,
+ ];
+
+ // Mark the disclaimer session flag.
+ AiSearch::setSessionCookie('shown_search_disclaimer', TRUE);
+ }
+ }
+ return $form;
+
+ }
+ /**
+ * AJAX callback to open the modal disclaimer form - not implemented.
+ */
+ public function ajaxOpenDisclaimerModalForm(array &$form, FormStateInterface $form_state) {
+ return new AjaxResponse();
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchInterface.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchInterface.php
new file mode 100644
index 0000000000..d10bc4fcc0
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchInterface.php
@@ -0,0 +1,58 @@
+formBuilder = $formBuilder;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+ * The Drupal service container.
+ *
+ * @return static
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('form_builder')
+ );
+ }
+
+ /**
+ * Callback for opening the modal form.
+ */
+ public function openModalForm(): AjaxResponse {
+
+ $config = AiSearch::getPresetValues();
+
+ if ($config && $config["searchform"]['disclaimer']['enabled']) {
+ // Check if disclaimer should be shown.
+ if (($config["searchform"]['disclaimer']['show_once'] && !AiSearch::getSessionCookie('shown_search_disclaimer'))
+ || !$config["searchform"]['disclaimer']['show_once']) {
+
+ // Show the interstitial (modal) disclaimer
+ $response = $this->openDisclaimerForm(AiSearch::getPreset());
+ AiSearch::setSessionCookie('shown_search_disclaimer', TRUE);
+ return $response;
+
+ }
+ }
+
+ $response = new AjaxResponse();
+
+ // Get the modal form using the form builder.
+ $modal_form = $this->formBuilder->getForm('Drupal\bos_search\Form\AiSearchForm');
+
+ // Ensure we have a preset in the search element.
+ if (empty($modal_form["AiSearchForm"]["content"]["preset"])) {
+ $preset = AiSearch::getPreset();
+ $search_preset = [
+ "#default_value" => $preset,
+ "#value" => $preset,
+ ];
+ $modal_form["AiSearchForm"]["search"]["preset"] = $modal_form["AiSearchForm"]["content"]["preset"] + $search_preset;
+ }
+
+ // Add an AJAX command to open a modal dialog with the form as the content.
+ $ui_options = [
+ 'width' => '85%',
+ 'maxWidth' => '85%',
+ "classes" => [
+ "ui-dialog" => "aisearch-modal-form ui-corner-all aienabledsearchform"
+ ],
+ "closeOnEscape" => TRUE,
+ 'closeText' => "Close this window",
+ ];
+ if (empty($modal_form["#modal_title"])) {
+ $ui_options["classes"]["ui-dialog-titlebar"] = "ui-titlebar-hidden";
+ }
+ $response->addCommand(new OpenModalDialogCommand(($modal_form["#modal_title"] ?? ""), $modal_form, $ui_options));
+ unset($modal_form["#modal_title"]);
+
+ return $response;
+ }
+
+ public function openDisclaimerForm(): AjaxResponse {
+ $response = new AjaxResponse();
+ $modal_form = $this->formBuilder->getForm('Drupal\bos_search\Form\AiDisclaimerForm');
+ // Add an AJAX command to open a modal dialog with the form as the content.
+ $rendered_form = \Drupal::service('renderer')->render($modal_form);
+ $ui_options = [
+ 'width' => '591px',
+ "classes" => [
+ "ui-dialog" => "aisearch-disclaimer-form ui-corner-all aienableddisclaimerform"
+ ],
+ "closeOnEscape" => TRUE,
+ 'closeText' => "Close this window",
+ ];
+ if (empty($modal_form["#modal_title"])) {
+ $ui_options["classes"]["ui-dialog-titlebar"] = "ui-titlebar-hidden";
+ }
+ $response->addCommand(new OpenModalDialogCommand(
+ ($modal_form["#modal_title"] ?: ""),
+ $rendered_form,
+ $ui_options
+ ));
+
+ AiSearch::setSessionCookie('shown_search_disclaimer', TRUE);
+ return $response;
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Controller/AutocompleteController.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Controller/AutocompleteController.php
new file mode 100644
index 0000000000..0d0a1c27d4
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Controller/AutocompleteController.php
@@ -0,0 +1,50 @@
+query->get('q');
+
+ if ($input && strlen($input) > 3) {
+
+ $nids = \Drupal::entityQuery('node')
+ ->condition('title', $input, 'CONTAINS')
+ ->sort('created', 'DESC')
+ ->accessCheck(0)
+ ->execute();
+ $nids = array_slice($nids, 0, 10);
+ $nodes = Node::loadMultiple($nids);
+
+ foreach ($nodes as $node) {
+ $titles[] = [
+ 'value' => "{$node->toUrl()->toString()}",
+ 'label' => "{$node->getTitle()}",
+ 'entity_id' => $node->id(),
+ 'description' => "{$node->getTitle()} ({$node->toUrl()->toString()})",
+ ];
+ }
+ }
+
+ return new JsonResponse($titles);
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiDisclaimerForm.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiDisclaimerForm.php
new file mode 100644
index 0000000000..e4172309b2
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiDisclaimerForm.php
@@ -0,0 +1,78 @@
+ ["library" => ["bos_search/core"]],
+ '#modal_title' => $config["searchform"]["modal_titlebartitle"] ?? "",
+ '#theme' => "disclaimer__{$config["searchform"]["theme"]}",
+ 'notice' => [
+ "#markup" => Markup::create($config["searchform"]["disclaimer"]["text"]),
+ ],
+ 'actions' => [
+ 'submit' => [
+ '#type' => 'submit',
+ '#value' => 'Continue',
+ '#attributes' => [
+ "class" => [
+ "btn-submit"
+ ],
+ ],
+ ],
+ 'cancel' => [
+ '#type' => 'button',
+ '#value' => 'Cancel',
+ '#access' => FALSE,
+ '#attributes' => [
+ "class" => [
+ "btn-cancel"
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ return $form;
+
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ // Not required.
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchConfigForm.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchConfigForm.php
new file mode 100644
index 0000000000..220dac4234
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchConfigForm.php
@@ -0,0 +1,1071 @@
+pluginManagerAiSearch = $plugin_manager_aisearch;
+ }
+
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('plugin.manager.aisearch'),
+ $container->get('config.typed')
+ );
+ }
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return 'bos_search_SearchConfigForm';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames(): array {
+ return ["bos_search.settings"];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+ $presets = AiSearch::getPresets();
+ $form = [
+ 'SearchConfigForm' => [
+ '#tree' => TRUE,
+ '#type' => 'fieldset',
+ '#title' => 'AI Search Configuration',
+ 'presets' => [
+ "#type" => "fieldset",
+ '#title' => "presets",
+ '#attributes' => ["id" => "edit-presets"],
+ '#description_display' => 'before',
+ '#description' => "You can define any number of presets and use these in search form implementations.",
+ ],
+ 'actions' => [
+ 'add' => [
+ "#type" => "button",
+ "#value" => "Add Preset",
+ '#ajax' => [
+ 'callback' => '::ajaxAddPreset',
+ 'event' => 'click',
+ 'wrapper' => 'edit-presets',
+ 'disable-refocus' => FALSE,
+ 'limit' => FALSE
+ ]
+ ]
+ ]
+ ],
+ ];
+
+ // Get and populate each existing preset.
+ foreach($presets as $pid => $preset) {
+ $form['SearchConfigForm']['presets'][$pid] = $this->preset($pid);
+ }
+
+ if ($form_state->isRebuilding()) {
+ // A rebuild does occur when an ajax button is clicked.
+ if ($form_state->getTriggeringElement()["#value"] == $form["SearchConfigForm"]["actions"]["add"]["#value"]) {
+ // The ajax "Add Preset" button has been clicked.
+ $this->addPreset($form, $form_state);
+ }
+ elseif ($form_state->getTriggeringElement()["#value"] == "Delete Preset") {
+ // An ajax "Delete Preset" button has been clicked.
+ $this->deletePreset($form, $form_state);
+ }
+ }
+
+ $form = parent::buildForm($form, $form_state);
+ return $form;
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state): void {
+ // Save the presets and any other fields to the settings config object.
+ $values = $form_state->getUserInput();
+ $config = $this->config('bos_search.settings');
+ $params = [];
+ foreach($values["SearchConfigForm"]["presets"] as &$preset) {
+ if (empty($preset['pid'])) {
+ $preset['pid'] = AiSearch::machineName($preset["name"]);
+ }
+ unset($preset["actions"]);
+ $params[$preset['pid']] = $preset;
+ }
+ $config->set("presets", $params);
+ $config->save();
+ parent::submitForm($form, $form_state); // optional
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state): void {
+ parent::validateForm($form, $form_state);
+
+ $values = $form_state->getValues();
+
+ foreach($values["SearchConfigForm"]["presets"] as $preset => $setting) {
+ $plugin = $this->pluginManagerAiSearch->createInstance($setting["plugin"]);
+ if (!$plugin->hasFollowup()) {
+ // TODO: should introduce a no-conversation version of the AiSearch component.
+ $form_state->setError($form["SearchConfigForm"]["presets"][$preset]["plugin"],"The selected Service ($preset) does not support conversations.");
+ }
+ }
+
+ }
+
+ /**
+ * Callback for Add Preset button on form.
+ *
+ * @param array $form
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ *
+ * @return mixed
+ */
+ public function ajaxAddPreset(array &$form, FormStateInterface $form_state) {
+ // The buildForm will have been called twice by this time, once as a build,
+ // and once as a rebuild.
+ return $form['SearchConfigForm']['presets'];
+ }
+
+ /**
+ * Add a New preset to the form object.
+ * NOTE: nothing is saved until the config form is saved (submitted)
+ *
+ * @param $form
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ *
+ * @return void
+ */
+ public function addPreset(&$form, FormStateInterface $form_state) {
+ $pid = count($form_state->getValues()['SearchConfigForm']['presets'] ?? []);
+ $form['SearchConfigForm']['presets'][$pid] = $this->preset();
+ $rand = intval(microtime(TRUE) * 1000);
+ foreach($form['SearchConfigForm']['presets'][$pid] as $key => &$preset) {
+ if (!str_contains($key, "#")) {
+ $preset["#id"] = "edit-searchconfigform-presets-$pid-$key--$rand";
+ $preset["#attributes"] = [
+ "data-drupal-selector" => "edit-searchconfigform-presets-$pid-$key",
+ ];
+ }
+ }
+ $form['SearchConfigForm']['presets'][$pid]["#title"] = "New Preset";
+ $form['SearchConfigForm']['presets'][$pid]["#open"] = TRUE;
+ $form['SearchConfigForm']['presets'][$pid]["#id"] = "edit-searchconfigform-presets-$pid--$rand";
+ $form['SearchConfigForm']['presets'][$pid]["#attributes"] = [
+ "data-drupal-selector" => "edit-searchconfigform-presets-$pid",
+ ];
+ }
+
+ /**
+ * Callback for Delete Preset button on form.
+ *
+ * @param array $form
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ *
+ * @return mixed
+ */
+
+ public function ajaxDeletePreset(array &$form, FormStateInterface $form_state) {
+ return $form['SearchConfigForm']['presets'];
+ }
+
+ /**
+ * Delete the selected Preset.
+ * NOTE: nothing is actually deleted until the config form is saved (submitted)
+ *
+ * @param array $form
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ *
+ * @return void
+ */
+ public function deletePreset(array &$form, FormStateInterface $form_state) {
+ $pid = $form_state->getTriggeringElement()['#attributes']['data-pid'];
+ unset($form['SearchConfigForm']['presets'][$pid]);
+ }
+
+ /**
+ * Defines a preset fieldset on the form.
+ *
+ * @param string $pid
+ *
+ * @return array
+ */
+ private function preset(string $pid = "") {
+
+ $preset = AiSearch::getPresetValues($pid) ?? [];
+
+ /**
+ * @var $service_plugins \Drupal\bos_search\Plugin\AiSearch\AiSearchPluginManager Registered AI Search Service Plugins
+ */
+ $service_plugins = $this->pluginManagerAiSearch->getDefinitions();
+ // Populate an array of service plugins.
+ $service_opts = [];
+ foreach($service_plugins as $service_plugin) {
+ $service_opts[$service_plugin["id"]] = $service_plugin["id"];
+ }
+ // Get info on the service this preset is referencing.
+ $this_service_plugin = $service_plugins[$preset["plugin"]];
+ $this_service_id = $this_service_plugin["service"];
+ $this_service = \Drupal::service($this_service_id);
+ $this_service_settings = $this_service->getSettings();
+
+ $project_name = $this->getProjects($this_service)[$this_service_settings["project_id"]];
+
+ $themes = AiSearch::getFormThemes();
+
+ $output = [
+ '#type' => 'details',
+ '#title' => (empty($preset) ? "": $preset['name']) . (empty($preset) ? "" : " (". $this_service->id() .")"),
+ '#open' => FALSE,
+
+ 'name' => [
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ '#title' => $this->t("Preset Name"),
+ "#default_value" => empty($preset) ? "" : ($preset['pid'] ?? ""),
+ '#placeholder' => "Enter the name for this preset",
+ ],
+ 'plugin' => [
+ '#type' => 'select',
+ '#options' => $service_opts,
+ "#default_value" => empty($preset) ? "" : ($preset['plugin'] ?? "") ,
+ '#title' => $this->t("Select the AI Service Plugin to use:"),
+ '#ajax' => [
+ 'callback' => '::ajaxCallbackChangedModel',
+ 'progress' => [
+ 'type' => 'throbber',
+ 'message' => "Reloading default configs ..."
+ ]
+ ],
+ ],
+ 'prompt' => [
+ '#type' => 'select',
+ '#options' => $this->getPrompts($this_service),
+ "#default_value" => empty($preset) ? "" : ($preset['prompt'] ?? "") ,
+ '#title' => $this->t("Select the prompt for the AI Model to use:"),
+ '#description' => $this->t("Prompts are set from the admin page for the model selected."),
+ '#description_display' => 'after',
+ '#prefix' => "
",
+ '#suffix' => "
",
+ ],
+ 'model_tuning' =>[
+ '#type' => "details",
+ '#title' => "Advanced AI Model Tuning",
+
+ 'overrides' => [
+ '#type' => "fieldset",
+ '#title' => 'Service Plugin Override',
+ '#description' => $this->t("The default Service Settings are set on the
Google Cloud Conversation configuration page ."),
+ '#description_display' => 'before',
+
+ 'service_account' => [
+ '#type' => 'select',
+ '#options' => $this->getServiceAccounts($this_service),
+ "#default_value" => empty($preset) ? "default" : ($preset['model_tuning']['overrides']['service_account'] ?? "default") ,
+ '#title' => $this->t("Override the default Service Account for the AI Model to use:"),
+ '#description' => $this->t("The current default Service Account is:
{$this_service_settings["service_account"]} "),
+ '#description_display' => 'after',
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#validated' => TRUE,
+ '#ajax' => [
+ 'callback' => '::ajaxCallbackGetServiceAccount',
+ 'event' => 'focus',
+ 'progress' => [
+ 'type' => 'throbber',
+ 'message' => "Finding Service Accounts ..."
+ ]
+ ],
+
+ ],
+
+ 'project_id' => [
+ '#type' => 'select',
+ '#options' => $this->getProjects($this_service, ($preset['model_tuning']['overrides']['service_account'] ?? "default")),
+ "#default_value" => empty($preset) ? "" : ($preset['model_tuning']['overrides']['project_id'] ?? ""),
+ '#title' => $this->t("Override the Project for the AI Model to use."),
+ '#description' => $this->t("Leave empty to use the default.
The current default Project is:
$project_name "),
+ '#description_display' => 'after',
+ '#validated' => TRUE,
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#ajax' => [
+ 'callback' => '::ajaxCallbackGetProjects',
+ 'event' => 'click',
+ 'progress' => [
+ 'type' => 'throbber',
+ 'message' => "Finding Projects ..."
+ ]
+ ],
+ ],
+ 'datastore_id' => [
+ '#type' => 'select',
+ '#options' => $this->getDatastores($this_service, ($preset['model_tuning']['overrides']['service_account'] ?? "default"), ($preset['model_tuning']['overrides']['project_id'] ?? "default")),
+ "#default_value" => empty($preset) ? "default" : ($preset['model_tuning']['overrides']['datastore_id'] ?? "default") ,
+ '#title' => $this->t("Override the default Datastore for the AI Model to use:"),
+ '#description' => $this->t("The current default dataStore is:
{$this_service_settings["datastore_id"]} "),
+ '#description_display' => 'after',
+ '#validated' => TRUE,
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#ajax' => [
+ 'callback' => '::ajaxCallbackGetDataStores',
+ 'event' => 'focus',
+ 'progress' => [
+ 'type' => 'throbber',
+ 'message' => "Finding Datastores ..."
+ ]
+ ],
+ ],
+ 'engine_id' => [
+ '#type' => 'select',
+ '#options' => $this->getEngines($this_service, ($preset['model_tuning']['overrides']['service_account'] ?? "default"), ($preset['model_tuning']['overrides']['project_id'] ?? "default")),
+ "#default_value" => empty($preset) ? "default" : ($preset['model_tuning']['overrides']['engine_id'] ?? "default") ,
+ '#title' => $this->t("Override the default Engine for the AI Model to use:"),
+ '#description' => $this->t("The current default engine is:
{$this_service_settings["engine_id"]} "),
+ '#description_display' => 'after',
+ '#validated' => TRUE,
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#ajax' => [
+ 'callback' => '::ajaxCallbackGetEngines',
+ 'event' => 'focus',
+ 'progress' => [
+ 'type' => 'throbber',
+ 'message' => "Finding Engines ..."
+ ]
+ ],
+ ],
+ ],
+ 'summary' => [
+ '#type' => "fieldset",
+ '#title' => 'Fine-tune Summarization',
+ 'ignoreAdversarialQuery' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreAdversarialQuery'] ?? 0),
+ '#title' => $this->t("Ignore Adverserial Queries."),
+ '#description' => 'When selected, no summary is returned if the search query is classified as an adversarial query. For example, a user might ask a question regarding negative comments about the company or submit a query designed to generate unsafe, policy-violating output.'
+ ],
+ 'ignoreNonSummarySeekingQuery' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreNonSummarySeekingQuery'] ?? 0),
+ '#title' => $this->t("Ignore Non-summary Seeking Queries."),
+ '#description' => 'When selected, no summary is returned if the search query is classified as a non-summary seeking query. For example, why is the sky blue and Who is the best soccer player in the world? are summary-seeking queries, but SFO airport and world cup 2026 are not.'
+ ],
+ 'ignoreLowRelevantContent' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreLowRelevantContent'] ?? 0),
+ '#title' => $this->t("Ignore Low Relevant Content."),
+ '#description' => 'When selected, only queries with high relevance search results will generate answers.'
+ ],
+ 'ignoreJailBreakingQuery' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreJailBreakingQuery'] ?? 0),
+ '#title' => $this->t("Ignore Jail-breaking Queries."),
+ '#description' => "When selected, search-query classification is applied to detect queries that attempts to exploit vulnerabilities or weaknesses in the model's design or training data. No summary is returned if the search query is classified as a jail-breaking query."
+ ],
+ 'semantic_chunks' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['model_tuning']['summary']['semantic_chunks'] ?? 0),
+ '#title' => $this->t("Enable Semantic Chunk Search."),
+ '#description' => 'When selected, the summary will be generated from most relevant chunks from top search results. This feature will improve summary quality. Note that with this feature enabled, not all top search results will be referenced and included in the reference list, so the citation source index only points to the search results listed in the reference list.'
+ ],
+ ],
+ 'search' => [
+ '#type' => "fieldset",
+ '#title' => 'Fine-tune Search',
+ 'safe_search' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['search']['safe_search'] ?? 0),
+ '#title' => $this->t("Enable Safe Search."),
+ '#description' => 'When selected, significantly reduces the level of explicit content that the system can display in the results. This is similar to the feature used in Google Search, where you can modify your settings to filter explicit content, such as nudity, violence, and other adult content, from the search results.'
+ ],
+ ],
+ ],
+ 'searchform' => [
+ '#type' => 'details',
+ '#collapsible' => TRUE,
+ '#title' => 'Search Form Configuration and Styling',
+ 'theme' => [
+ '#type' => 'select',
+ '#options' => $themes,
+ "#default_value" => empty($preset) ? "" : ($preset['results']['theme'] ?? "") ,
+ '#title' => $this->t("Select the theme for the form configured by this preset"),
+ ],
+ 'disclaimer' => [
+ '#type' => 'fieldset',
+ '#title' => $this->t("Pop-up Disclaimer"),
+ '#description' => $this->t("Control the presence and content of an interstitial disclaimer which shows before the search form is shown."),
+ '#description_display' => 'before',
+ 'enabled' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['searchform']['disclaimer']['enabled'] ?? 0),
+ '#title' => $this->t("Show disclaimer window"),
+ ],
+ 'show_once' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['searchform']['disclaimer']['show_once'] ?? 0),
+ '#title' => $this->t("Only Show Once"),
+ '#description' => $this->t("When checked, the disclaimer window will only appear the first time the search form loads, when unselected the disclaimer window will show every time the search form opens for the user. This is a session-based rule."),
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][disclaimer][enabled]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'text' => [
+ '#type' => 'textarea',
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['disclaimer']['text'] ?? ""),
+ '#title' => $this->t("Popup Disclaimer"),
+ '#description' => $this->t("Disclaimer text to appear as an interstitial popup when first showing the form."),
+ '#description_display' => 'before',
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][disclaimer][enabled]"]' => ['checked' => TRUE],
+ ],
+ 'required' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][disclaimer][enabled]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ ],
+ 'modal_titlebartitle' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Modal Form Title"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['modal_titlebartitle'] ?? ""),
+ '#description' => $this->t("Leave blank for no title on the search form when it is a modal window."),
+ '#description_display' => 'before',
+ ],
+ 'welcome' => [
+ '#type' => 'fieldset',
+ '#title' => $this->t("Main Form Body"),
+ '#description' => $this->t("Configure the initial information displayed to the user"),
+ '#description_display' => 'before',
+ 'body_title' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Form Body Title"),
+ "#default_value" => empty($preset) ? 0 : ($preset['searchform']['welcome']['body_title'] ?? ""),
+ '#placeholder' => "What are you looking for?",
+ '#description' => $this->t("Add a title for the search form. Can be blank."),
+ '#description_display' => 'after',
+ ],
+ 'body_text' => [
+ '#type' => 'textarea',
+ '#title' => $this->t("Form Body Copy"),
+ "#default_value" => empty($preset) ? 0 : ($preset['searchform']['welcome']['body_text'] ?? ""),
+ '#description' => $this->t("Add follow-on/body copy to appear on the search form. Can be blank."),
+ '#description_display' => 'before',
+ ],
+ 'cards' =>[
+ '#type' => 'fieldset',
+ '#title' => $this->t("Example/Suggested Searches"),
+ '#description' => $this->t("Example search terms presented as cards"),
+ 'enabled' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['searchform']['welcome']['cards']['enabled'] ?? 0),
+ '#title' => $this->t("Enable cards."),
+ ],
+ 'card_1' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Example Question 1"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['welcome']["cards"]['card_1'] ?? ""),
+ '#placeholder' => "How do I open a new business in Boston?",
+ '#description' => $this->t("Enter text for the example question to place in the card."),
+ '#description_display' => 'after',
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE],
+ ],
+ 'required' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'card_2' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Example Question 2"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['welcome']["cards"]['card_2'] ?? ""),
+ '#placeholder' => "When is the next meeting for the small business forum?",
+ '#description' => $this->t("Enter text for the example question to place in the card."),
+ '#description_display' => 'after',
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE],
+ ],
+ 'required' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'card_3' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Example Question 3"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['welcome']["cards"]['card_3'] ?? ""),
+ '#placeholder' => "How do I become a certified Boston Equity Applicant?",
+ '#description' => $this->t("Enter text for the example question to place in the card."),
+ '#description_display' => 'after',
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE],
+ ],
+ 'required' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ ],
+ ],
+
+ 'searchbar' => [
+ '#type' => 'fieldset',
+ '#title' => $this->t("Searchbar Configuration"),
+ '#description' => $this->t("Display settings for the main search bar"),
+ '#description_display' => 'before',
+ 'allow_conversation' => [
+ '#type' => 'checkbox',
+ '#title' => $this->t("Allow follow-on questions during search."),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['allow_reset'] ?? 0),
+ ],
+ 'allow_reset' => [
+ '#type' => 'checkbox',
+ '#title' => $this->t("Allow the user to reset the search history"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['allow_reset'] ?? 0),
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][searchbar][allow_conversation]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'search_text' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Search Prompt"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['search_text'] ?? ""),
+ '#placeholder' => "How can we help you ?"
+ ],
+ 'waiting_text' => [
+ '#type' => 'textfield',
+ '#title' => $this->t("Text to show in searchbar when waiting for search results"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['waiting_text'] ?? ""),
+ '#placeholder' => "Searching Boston.gov?"
+ ],
+ 'audio_search_input' => [
+ '#type' => 'checkbox',
+ '#title' => $this->t("Allow Audio input to searchbar"),
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['audio_search_input'] ?? 0),
+ ],
+ 'search_note' => [
+ '#type' => 'textarea',
+ "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['search_note'] ?? ""),
+ '#title' => $this->t("Search Help"),
+ '#description' => $this->t("Any help notes to appear under the search box. Can be left blank."),
+ '#description_display' => 'after',
+ ],
+ ],
+ ],
+ 'results' => [
+ '#type' => 'details',
+ '#collapsible' => TRUE,
+ '#title' => 'Search Results Configuration',
+ 'result_count' => [
+ '#type' => 'select',
+ '#options' => [
+ 0 => "All",
+ 1 => "1",
+ 3 => "3",
+ 5 => "5",
+ 10 => "10",
+ 15 => "15",
+ 20 => "20",
+ ],
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['result_count'] ?? 0) ,
+ '#title' => $this->t("How many results should be returned?"),
+ ],
+ 'summary' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['summary'] ?? 0),
+ '#title' => $this->t("Show AI Model generated summary text in results output."),
+ ],
+ 'no_result_text' => [
+ '#type' => 'textarea',
+ "#default_value" => empty($preset) ? "" : ($preset['results']['no_result_text'] ?? ""),
+ '#title' => $this->t("No Results Text"),
+ '#description' => $this->t("Text that should appear when the AI Model is unable to answer a question."),
+ '#description_display' => 'after',
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][results][summary]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'violations_text' => [
+ '#type' => 'textarea',
+ "#default_value" => empty($preset) ? "" : ($preset['results']['violations_text'] ?? ""),
+ '#title' => $this->t("Query Violations Text"),
+ '#description' => $this->t("Text that should appear when the question fed to the AI Model was rejected."),
+ '#description_display' => 'after',
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][results][summary]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'citations' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['citations'] ?? 0),
+ '#title' => $this->t("Show citations in results output (if available)."),
+ '#states' => [
+ 'visible' => [
+ ':input[name="SearchConfigForm[presets][' . $pid . '][results][summary]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ],
+ 'min_citation_relevance' => [
+ '#type' => 'select',
+ '#options' => [
+ "0" => "Show All",
+ "0.3" => "0.3",
+ "0.5" => "0.5",
+ "0.6" => "0.6",
+ "0.7" => "0.7",
+ "0.75" => "0.75",
+ "0.8" => "0.8",
+ "0.85" => "0.85",
+ "0.9" => "0.9",
+ "0.95" => "0.95",
+ ],
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['min_citation_relevance'] ?? 0) ,
+ '#title' => $this->t("The minimum relevance for sitations to appear in list."),
+ '#description' => $this->t("References with relevance scores below this number will be suppressed in Citations marked in the Summary."),
+ '#description_display' => "below",
+ '#states' => [
+ 'visible' => [[
+ ':input[name="SearchConfigForm[presets][' . $pid . '][results][citations]"]' => ['checked' => TRUE],
+ ]],
+ ],
+ ],
+ 'searchresults' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['searchresults'] ?? 0),
+ '#title' => $this->t("Show search results in results output."),
+ ],
+ 'no_dup_citations' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['no_dup_citations'] ?? 0),
+ '#title' => $this->t("Remove search result links that already appear in the citations listing."),
+ '#states' => [
+ 'visible' => [[
+ ':input[name="SearchConfigForm[presets][' . $pid . '][results][citations]"]' => ['checked' => TRUE],
+ ':input[name="SearchConfigForm[presets][' . $pid . '][results][searchresults]"]' => ['checked' => TRUE],
+ ]],
+ ],
+ ],
+ 'related_questions' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['related_questions'] ?? 0),
+ '#title' => $this->t("Show related questions (suggested questions) after query results."),
+ ],
+ 'feedback' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['feedback'] ?? 0),
+ '#title' => $this->t("Show feedback buttons below results output."),
+ ],
+ 'metadata' => [
+ '#type' => 'checkbox',
+ "#default_value" => empty($preset) ? 0 : ($preset['results']['metadata'] ?? 0),
+ '#title' => $this->t("Show AI Model metadata in results output (if available)."),
+ ],
+ 'pid' => [
+ '#type' => 'hidden',
+ "#default_value" => $pid,
+ "#value" => $pid,
+ ],
+ ],
+ 'actions' => [
+ '#type' => "button",
+ '#value' => "Delete Preset",
+ '#attributes' => [
+ "class" => [
+ "button--danger"
+ ],
+ "data-pid" => "$pid",
+ ],
+ '#ajax' => [
+ 'callback' => '::ajaxDeletePreset',
+ 'event' => 'click',
+ 'wrapper' => 'edit-presets',
+ 'disable-refocus' => FALSE,
+ 'limit' => FALSE
+ ]
+ ],
+ ];
+
+ if (!isset($pid) || $pid == "") {
+ // Configure for a new Preset.
+ unset($output["actions"]);
+ foreach($output as &$row) {
+ if (is_array($row) && $row["#type"] == "textarea") {
+ $row["#value"] = "";
+ }
+ }
+
+ }
+
+ if (!empty($preset['model_tuning']['overrides']['service_account']) && $preset['model_tuning']['overrides']['service_account'] != "default") {
+ $output["model_tuning"]["overrides"]["service_account"]["#value"] = $preset['model_tuning']['overrides']['service_account'];
+ if (!array_key_exists($preset["model_tuning"]["overrides"]["service_account"],$output["model_tuning"]["overrides"]["service_account"]["#options"] )) {
+ $output["model_tuning"]["overrides"]["service_account"]["#options"][$preset["model_tuning"]["overrides"]["service_account"]] = $preset["model_tuning"]["overrides"]["service_account"];
+ }
+ }
+
+ if (!empty($preset['model_tuning']['overrides']['datastore_id']) && $preset['model_tuning']['overrides']['datastore_id'] != "default") {
+ $output["model_tuning"]["overrides"]["datastore_id"]["#value"] = $preset['model_tuning']['overrides']['datastore_id'];
+ if (!array_key_exists($preset["model_tuning"]["overrides"]["datastore_id"],$output["model_tuning"]["overrides"]["datastore_id"]["#options"] )) {
+ $output["model_tuning"]["overrides"]["datastore_id"]["#options"][$preset["model_tuning"]["overrides"]["datastore_id"]] = $preset["model_tuning"]["overrides"]["datastore_id"];
+ }
+ }
+
+ if (!empty($preset['model_tuning']['overrides']['engine_id']) && $preset['model_tuning']['overrides']['engine_id'] != "default") {
+ $output["model_tuning"]["overrides"]["engine_id"]["#value"] = $preset['model_tuning']['overrides']['engine_id'];
+ if (!array_key_exists($preset["model_tuning"]["overrides"]["engine_id"],$output["model_tuning"]["overrides"]["engine_id"]["#options"] )) {
+ $output["model_tuning"]["overrides"]["engine_id"]["#options"][$preset["model_tuning"]["overrides"]["engine_id"]] = $preset["model_tuning"]["overrides"]["engine_id"];
+ }
+ }
+
+ return $output;
+
+ }
+
+ /**
+ * Handles AJAX callbacks for getting the service account for the form.
+ *
+ * Service Accounts are defined via the bos_google_cloud config form.
+ *
+ * @param array $form
+ * The form array.
+ * @param FormStateInterface $form_state
+ * The current state of the form.
+ *
+ * @return AjaxResponse
+ * The response containing the updated service account selection options.
+ */
+ public function ajaxCallbackGetServiceAccount(array $form, FormStateInterface $form_state): AjaxResponse {
+ $trigger = $form_state->getTriggeringElement();
+ $active_preset_id = $trigger["#parents"][2];
+ $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id];
+ // Find the selected service and its prompts
+ $service_plugins = $this->pluginManagerAiSearch->getDefinitions();
+ $this_service_plugin = $service_plugins[$active_preset["plugin"]];
+ $this_service = \Drupal::service($this_service_plugin["service"]);
+
+ $output = new AjaxResponse();
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id);
+ $html = "";
+ foreach ($this->getServiceAccounts($this_service) as $key => $service) {
+ if ($trigger["#value"] && $trigger["#value"] == $key) {
+ $html .= '
' . $service . ' ';
+ }
+ else {
+ $html .= '
' . $service . ' ';
+ }
+ }
+ $output->addCommand(new HtmlCommand($target_preset . ' #edit-svsact select', $html));
+
+ return $output;
+
+ }
+
+ /**
+ * Handles AJAX callback for changing the model in the form.
+ *
+ * @param array $form The form structure array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state The current state of the form.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse The response object containing AJAX commands to update the form.
+ */
+ public function ajaxCallbackChangedModel(array $form, FormStateInterface $form_state): AjaxResponse {
+
+ // Get info from submitted form changes
+ $trigger = $form_state->getTriggeringElement();
+ $active_preset_id = $trigger["#parents"][2];
+ $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id];
+ // Find the selected service and its prompts
+ $service_plugins = $this->pluginManagerAiSearch->getDefinitions();
+ $this_service_plugin = $service_plugins[$trigger['#value']];
+ $this_service = \Drupal::service($this_service_plugin["service"]);
+ $prompts = $this->getPrompts($this_service);
+ $this_service_settings = $this_service->getSettings();
+ $project_name = $this->getProjects($this_service)[$this_service_settings["project_id"]];
+
+ $output = new AjaxResponse();
+
+ // Update the prompts available to this service. If the current
+ // prompt exists, then use it, otherwise use the "default"
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id);
+ $output->addCommand(new ReplaceCommand($target_preset . ' #edit-prompt', [
+ 'prompt' => [
+ '#type' => 'select',
+ '#options' => $prompts,
+ '#title' => $this->t("Select the prompt for the AI Model to use:"),
+ "#default_value" => empty($prompts) ? "default" : ($prompts[$active_preset['prompt']] ?? "default") ,
+ '#description' => $this->t("Prompts are set from the admin page for the model selected."),
+ '#description_display' => 'after',
+ '#prefix' => "
",
+ '#suffix' => "
",
+ ]
+ ]));
+ // Set notification below Service Account
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-service-account--description b";
+ $output->addCommand(new HtmlCommand($target_preset, $this_service_settings["service_account"]));
+ // Set notification below Project
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-project-id--description b";
+ $output->addCommand(new HtmlCommand($target_preset, $project_name));
+ // Set notification below DataStore
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-datastore-id--description b";
+ $output->addCommand(new HtmlCommand($target_preset, $this_service_settings["datastore_id"]));
+ // Set notification below Engine
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-engine-id--description b";
+ $output->addCommand(new HtmlCommand($target_preset, $this_service_settings["engine_id"]));
+
+ return $output;
+
+ }
+
+ /**
+ * Handles the AJAX callback to get the list of projects and update the project
+ * selection options in the form.
+ *
+ * Projects are read from Google Cloud
+ *
+ *
+ * @param array $form The form array containing the form elements.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state The current state of the form.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse The AJAX response with the updated project options.
+ */
+ public function ajaxCallbackGetProjects(array $form, FormStateInterface $form_state): AjaxResponse {
+ // Get info from submitted form changes
+ $trigger = $form_state->getTriggeringElement();
+ $active_preset_id = $trigger["#parents"][2];
+ $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id];
+ // Find the selected service and its prompts
+ $service_plugins = $this->pluginManagerAiSearch->getDefinitions();
+ $this_service_plugin = $service_plugins[$active_preset["plugin"]];
+ $service = \Drupal::service($this_service_plugin["service"]);
+
+ $output = new AjaxResponse();
+ $html = "";
+ $p = $form_state->getUserInput()['SearchConfigForm']['presets'][$active_preset_id]['model_tuning']['overrides']['project_id'] ?: NULL;
+ foreach ($this->getProjects($service) as $key => $project) {
+ if ($trigger["#value"] && $p == $key) {
+ $html .= '
' . $project . ' ';
+ }
+ else {
+ $html .= '
' . $project . ' ';
+ }
+ }
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id);
+ $output->addCommand(new HtmlCommand($target_preset . ' #edit-project select', $html));
+ return $output;
+ }
+
+ /**
+ * Handles AJAX callbacks to update datastores in the form.
+ *
+ * Datastores are read from Google Cloud
+ *
+ * @param array $form The current state of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state The state of the form.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse The response object containing set of commands to update the form.
+ */
+ public function ajaxCallbackGetDataStores(array $form, FormStateInterface $form_state): AjaxResponse {
+
+ // Get info from submitted form changes
+ $trigger = $form_state->getTriggeringElement();
+ $active_preset_id = $trigger["#parents"][2];
+ $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id];
+ $overrides = $active_preset["model_tuning"]["overrides"];
+ // Find the selected service and its prompts
+ $service_plugins = $this->pluginManagerAiSearch->getDefinitions();
+ $this_service_plugin = $service_plugins[$active_preset["plugin"]];
+ $service = \Drupal::service($this_service_plugin["service"]);
+
+ $output = new AjaxResponse();
+
+ // Update the datastores available to this project. If the current
+ // datastore exists, then use it, otherwise use the "default"
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides";
+ $service_account = $overrides["service_account"] == "default" ? "" : $overrides["service_account"];
+ $project = $overrides["project_id"] == "default" ? "" : $overrides["project_id"];
+
+ $html = "";
+ $ds = $form_state->getUserInput()['SearchConfigForm']['presets'][$active_preset_id]['model_tuning']['overrides']['datastore_id'] ?: NULL;
+ $found_datastores = $this->getDatastores($service, $service_account, $project);
+ $new_datastore = array_key_first($found_datastores);
+ foreach ($found_datastores as $key => $datastore) {
+ if ($trigger["#value"] && $ds == $key || count($found_datastores) == 1) {
+ $html .= '
' . $datastore . ' ';
+ $new_datastore = $key;
+ }
+ else {
+ $html .= '
' . $datastore . ' ';
+ }
+ }
+ $output->addCommand(new HtmlCommand($target_preset . ' #edit-datastore select', $html));
+ $output->addCommand(new InvokeCommand($target_preset . ' #edit-datastore select', 'attr', ['value', $new_datastore]));
+
+ return $output;
+
+ }
+
+ /**
+ * Handles AJAX callbacks for retrieving and updating available engines based on form input.
+ *
+ * Engines are read from Google Cloud
+ *
+ * @param array $form The form structure array.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state The current state of the form.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse Contains commands to update the frontend with available engines.
+ */
+ public function ajaxCallbackGetEngines(array $form, FormStateInterface $form_state): AjaxResponse {
+
+ // Get info from submitted form changes
+ $trigger = $form_state->getTriggeringElement();
+ $active_preset_id = $trigger["#parents"][2];
+ $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id];
+ $overrides = $active_preset["model_tuning"]["overrides"];
+ // Find the selected service and its prompts
+ $service_plugins = $this->pluginManagerAiSearch->getDefinitions();
+ $this_service_plugin = $service_plugins[$active_preset["plugin"]];
+ $service = \Drupal::service($this_service_plugin["service"]);
+
+ $output = new AjaxResponse();
+
+ // Update the datastores available to this project. If the current
+ // datastore exists, then use it, otherwise use the "default"
+ $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning";
+ $service_account = $overrides["service_account"] == "default" ? "" : $overrides["service_account"];
+ $project = $overrides["project_id"] == "default" ? "" : $overrides["project_id"];
+
+ $html = "";
+ $ds = $form_state->getUserInput()['SearchConfigForm']['presets'][$active_preset_id]['model_tuning']['overrides']['engine_id'] ?: NULL;
+
+ $found_engines = $this->getEngines($service, $service_account, $project);
+ $new_engine = array_key_first($found_engines);
+ foreach ($found_engines as $key => $engine) {
+ if ($trigger["#value"] && $ds == $key) {
+ $html .= '
' . $engine . ' ';
+ $new_engine = $key;
+ }
+ else {
+ $html .= '
' . $engine . ' ';
+ }
+ }
+ $output->addCommand(new HtmlCommand($target_preset . ' #edit-engine select', $html));
+ $output->addCommand(new InvokeCommand($target_preset . ' #edit-engine select', 'attr', ['value', $new_engine]));
+
+ return $output;
+
+ }
+
+ /**
+ * Retrieves an array of available prompts from the given service.
+ *
+ * @param GcServiceInterface $service The service instance to retrieve prompts from.
+ *
+ * @return array An array of available prompts.
+ */
+ private function getPrompts(GcServiceInterface $service) {
+ return $service->availablePrompts();
+ }
+
+ /**
+ * Retrieves a list of service accounts configured in the GCAPI settings.
+ *
+ * @param GcServiceInterface $service The service interface instance used for fetching the settings.
+ *
+ * @return array An associative array of service accounts where keys and values represent the account names.
+ */
+ private function getServiceAccounts(GcServiceInterface $service): array {
+ $settings = CobSettings::getSettings("GCAPI_SETTINGS", "bos_google_cloud");
+ $output = ["default" => "use default"];
+ foreach ($settings["auth"] as $acct => $setting) {
+ $output[$acct] = $acct;
+ }
+ return $output;
+ }
+
+ /**
+ * Retrieves a list of projects from the given service.
+ *
+ * @param GcServiceInterface $service
+ * The service from which to retrieve the projects.
+ *
+ * @return array
+ * An associative array of project identifiers and their corresponding names.
+ */
+ private function getProjects(GcServiceInterface $service, ?string $service_account = NULL): array {
+ return ["default" => "use default"] + $service->availableProjects($service_account);
+ }
+
+ /**
+ * Retrieves an array of available datastores combined with a default option.
+ *
+ * @param GcServiceInterface|GcAgentBuilderInterface $service The service instance to retrieve datastores from.
+ * @param string|null $service_account The service account to use, or NULL to use the default.
+ * @param string|null $project The project to query datastores for, or NULL to use the default project.
+ *
+ * @return array An array of available datastores with a default option included.
+ */
+ private function getDatastores(GcServiceInterface|GcAgentBuilderInterface $service, ?string $service_account = NULL, ?string $project = NULL): array {
+ return ["default" => "use default"] + $service->availableDatastores($service_account, $project);
+ }
+
+ /**
+ * @param \Drupal\bos_google_cloud\Services\GcServiceInterface|\Drupal\bos_google_cloud\Services\GcAgentBuilderInterface $service
+ * @param string|null $service_account
+ * @param string|null $project_id
+ *
+ * @return string[]
+ */
+ private function getEngines(GcServiceInterface|GcAgentBuilderInterface $service, ?string $service_account = NULL, ?string $project_id = NULL): array {
+ return ["default" => "use default"] + $service->availableEngines($service_account, $project_id);
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchForm.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchForm.php
new file mode 100644
index 0000000000..80a3932fe3
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchForm.php
@@ -0,0 +1,297 @@
+ ["library" => ["bos_search/core"]],
+ '#modal_title' => $config["searchform"]["modal_titlebartitle"] ?? "",
+ ];
+
+ $preset = $form_state->getBuildInfo()["args"][0];
+ if (empty($preset)) {
+ $preset = AiSearch::getPreset();
+ }
+ $config = $this->config("bos_search.settings")->get("presets.$preset");
+ if (empty($config)) {
+ $form += [
+ '#errors' => true,
+ "problem" => [
+ "message" => [
+ "#markup" => "
Configuration Error: The Preset for this form is not correctly setup. Please set up a configuration at /admin/config/system/boston/aisearch
"
+ ],
+ ],
+ ];
+ return $form;
+ }
+
+ $form += [
+ 'AiSearchForm' => [
+ '#tree' => FALSE,
+
+ 'content' => [
+ '#type' => 'container',
+ '#attributes' => [
+ 'id' => ['edit-aisearchform'],
+ ],
+ 'preset' => [
+ '#type' => 'hidden',
+ '#default_value' => $preset,
+ ],
+ 'messages' => [
+ '#type' => 'container',
+ '#attributes' => ['id' => ['edit-messages']],
+ ],
+ 'searchresults' => [
+ '#type' => 'container',
+ '#attributes' => ['id' => ['edit-searchresults']],
+ 'welcome' => [
+ '#type' => 'container',
+ '#attributes' => [
+ "id" => "edit-welcome",
+ ],
+ "title" => [
+ '#markup' => Markup::create($config["searchform"]['welcome']["body_title"])
+ ],
+ "body" => [
+ '#markup' => Markup::create($config["searchform"]['welcome']["body_text"])
+ ],
+ "cards" => [
+ '#type' => 'grid_of_cards',
+ '#theme' => 'grid_of_cards',
+ "#title" => "Example",
+ "#title_attributes" => [],
+ '#cards' => [
+ [
+ '#type' => 'card',
+ '#theme' => 'card',
+ '#attributes' => [
+ 'class' => ['br--4', "bg--lb"]
+ ],
+ '#content' => $config["searchform"]['welcome']["cards"]["card_1"] ?: "",
+ ],
+ [
+ '#type' => 'card',
+ '#theme' => 'card',
+ '#attributes' => [
+ 'class' => ['br--4', "bg--lb"]
+ ],
+ '#content' => $config["searchform"]['welcome']["cards"]["card_2"] ?: "",
+ ],
+ [
+ '#type' => 'card',
+ '#theme' => 'card',
+ '#attributes' => [
+ 'class' => ['br--4', "bg--lb"]
+ ],
+ '#content' => $config["searchform"]['welcome']["cards"]["card_3"] ?: "",
+ ],
+ ]
+ ],
+ ],
+ ],
+ 'session_id' => [
+ '#type' => 'hidden',
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#default_value' => $form_state->getValue("session_id") ?: "",
+ ],
+ ],
+ 'actions' => [
+ '#type' => 'button',
+ '#value' => 'Search',
+ "#attributes" => [
+ "class" => ["hidden"],
+ ],
+ '#ajax' => [
+ 'callback' => '::ajaxCallbackSearch',
+ 'progress' => [
+ 'type' => 'none',
+ ]
+ ],
+ ],
+ 'searchbar' => [
+ '#theme' => 'search_bar',
+ '#default_value' => "",
+ '#audio_search_input' => $config["searchform"]['searchbar']["audio_search_input"] ?? FALSE,
+ '#attributes' => [
+ "placeholder" => $config["searchform"]['searchbar']["search_text"] ?? "",
+ ],
+ "#description" => $config["searchform"]['searchbar']["search_note"] ?? "",
+ "#description_display" => "after",
+ ],
+ ],
+ ];
+
+ if (!$config["searchform"]["welcome"]["cards"]["enabled"]) {
+ unset($form["AiSearchForm"]['content']["searchresults"]["welcome"]["cards"]);
+ }
+
+ if ($config["results"]["feedback"] ?: 0) {
+ $form["#attached"]["library"][] = "bos_search/snippet.search_feedback";
+ }
+
+ return $form;
+
+ }
+
+ /**
+ * Ajax callback to run the desired test against the selected AI Model.
+ *
+ * @param array $form
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ *
+ * @return array
+ * @throws \Exception
+ */
+ public function ajaxCallbackSearch(array $form, FormStateInterface $form_state): AjaxResponse {
+ if ($form_state->getErrors()) {
+ return new AjaxResponse();
+ }
+ $config = \Drupal::config("bos_search.settings")->get("presets");
+ $form_values = $form_state->getUserInput();
+ $fake = FALSE; // TRUE = don't actually send to AI Model.
+
+ try {
+
+ // Find the plugin being used (from the preset).
+ $preset_name = $form_state->getBuildInfo()["args"][0] ?: $config[$form_values["preset"]];
+ $preset = $config[$preset_name] ?: FALSE;
+ if (!$preset) {
+ throw new \Exception("Cannot find the preset {$preset_name}.}");
+ }
+ if (empty($preset['plugin'])) {
+ throw new \Exception("The preset {$preset['plugin']} is not defined.");
+ }
+ $plugin_id = $preset["plugin"];
+
+ // Create the search request object.
+ $request = new AiSearchRequest($form_values["searchbar"], $preset['results']["result_count"] ?? 0);
+ $request->set("preset", $preset);
+
+ if ($preset["searchform"]["searchbar"]["allow_conversation"]
+ && !empty($form_values["session_id"])) {
+ // Set the conversationid. This causes any history for the conversation
+ // to be reloaded into the $request object.
+ $request->set("session_id", $form_values["session_id"]);
+ }
+
+ // Instantiate the plugin, and call the search using the search object.
+ /** @var \Drupal\bos_search\AiSearchInterface $plugin */
+ $plugin = \Drupal::service("plugin.manager.aisearch")
+ ->createInstance($plugin_id);
+
+ $response = $plugin->search($request, $fake);
+
+ }
+ catch (\Exception $e) {
+ $output = new AjaxResponse();
+ $output->addCommand(new AppendCommand('#search-conversation-wrapper', [
+ "#markup" => "
+
+
+
+ There was an error with this query, please try again.
+
+
+ {$e->getMessage()}
+
+
+
+"
+ ]));
+ $output->addCommand(new ReplaceCommand('#edit-session_id', [
+ 'session_id' => [
+ '#type' => 'hidden',
+ '#attributes' => [
+ "data-drupal-selector" => "edit-conversation-id",
+ "name" => "session_id",
+ ],
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#value' => $request->get("session_id") ?: "",
+ ]
+ ]));
+ $output->addCommand(new SettingsCommand(["has_results" => TRUE], TRUE));
+
+ return $output;
+ }
+
+ // Save this search so we can continue the conversation later
+ if ($preset["searchform"]["searchbar"]["allow_conversation"] && $request->get("session_id") != $response->getAll()["session_id"]) {
+ // Either the session_id was not yet created, or else the session
+ // for the original conversation has timed-out.
+ // Load the session_id into the request.
+ $request->set("session_id", $response->getAll()["session_id"]);
+ }
+ $request->addHistory($response);
+ $request->save();
+
+ // This will render the output form using the input array.
+ $rendered_result = [
+ "#type" => "inline_template",
+ "#template" => $response->build()
+ ];
+ $output = new AjaxResponse();
+ $output->addCommand(new AppendCommand('#search-conversation-wrapper', $rendered_result));
+ $output->addCommand(new ReplaceCommand('#edit-session_id', [
+ 'session_id' => [
+ '#type' => 'hidden',
+ '#attributes' => [
+ "data-drupal-selector" => "edit-conversation-id",
+ "name" => "session_id",
+ ],
+ '#prefix' => "
",
+ '#suffix' => "
",
+ '#value' => $request->get("session_id") ?: "",
+ ]
+ ]));
+ $output->addCommand(new SettingsCommand(["has_results" => TRUE], TRUE));
+
+ return $output;
+
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ // Not required for this form.
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitation.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitation.php
new file mode 100644
index 0000000000..e86111b89a
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitation.php
@@ -0,0 +1,66 @@
+startIndex = $startIndex;
+ $this->endIndex = $endIndex;
+ $this->sources = $sources;
+ }
+
+ /**
+ * Returns an array with all the properties of this class.
+ *
+ * @return array
+ */
+ public function getCitation(): array {
+ return [
+ "startIndex" => $this->startIndex,
+ "endIndex" => $this->endIndex,
+ "sources" => $this->sources,
+ ];
+ }
+
+ /**
+ * Adds a new source (reference) to this citation.
+ *
+ * @param int $referenceIndex
+ *
+ * @return \use Drupal\bos_search\Model\AiSearchCitation Returns the instance of the AIsearchReference for method chaining.
+ */
+ public function addSource(array $source, int $key): AiSearchCitation {
+ if (empty($key)) {
+ $key = count($this->sources ?? []);
+ }
+ $this->sources[$key] = [
+ "referenceIndex" => $source["referenceIndex"],
+ "relevanceScore" => $source["relevanceScore"],
+ ];
+ return $this;
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitationCollection.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitationCollection.php
new file mode 100644
index 0000000000..d78ddf2b85
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitationCollection.php
@@ -0,0 +1,68 @@
+citations ?? []);
+ }
+ // Check for duplicates.
+ $cit = $citation->getCitation();
+ if (isset($this->citations)) {
+ foreach ($this->citations as &$existing_citation) {
+ if ($existing_citation['startIndex'] == $cit['startIndex']) {
+ $existing_citation["sources"] = array_merge($existing_citation['sources'], $cit['sources']);
+ return $this;
+ }
+ }
+ }
+
+ $this->citations[$key] = $cit;
+ return $this;
+ }
+
+ public function updateCitation($key, array $citation):AiSearchCitationCollection {
+ $this->citations[$key] = $citation;
+ return $this;
+ }
+ /**
+ * Get all results as an array of AISearchCitation objects.
+ *
+ * @return array
+ */
+ public function getCitations(): array {
+ return $this->citations;
+ }
+
+ /**
+ * Returns the number of AISearchCitation objects in the collection.
+ * @return int
+ */
+ public function count(): int {
+ return count($this->citations);
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsBase.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsBase.php
new file mode 100644
index 0000000000..1bbad025f1
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsBase.php
@@ -0,0 +1,79 @@
+{$key} = array_merge($this->{$key} ?? [], $value);
+ }
+ else {
+ $this->{$key} = $value;
+ }
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get(string $key): mixed {
+ return $this->{$key} ?? NULL;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function toArray(): array {
+ return $this->trimArray($this->object) ?: [];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function toJson(): string {
+ return json_encode($this->toArray());
+ }
+
+ /**
+ * Removes elements in the array which been set to null.
+ *
+ * @param $array
+ *
+ * @return NULL|array
+ */
+ private function trimArray($array): NULL|array {
+ $output = [];
+ foreach ($array as $key => $value) {
+ if ($value != NULL) {
+ if (is_object($value)) {
+ $newval = $this->trimArray($value->toArray());
+ if ($newval !== NULL) {
+ $output[$key] = $newval;
+ }
+ }
+ else {
+ $output[$key] = $value;
+ }
+ }
+ }
+ return empty($output) ? NULL : $output;
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsInterface.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsInterface.php
new file mode 100644
index 0000000000..a4fcc81e14
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsInterface.php
@@ -0,0 +1,38 @@
+title = $title;
+ $this->uri = $uri;
+ $this->ref = $ref;
+ }
+
+ /**
+ * Returns an array with all the properties of this class.
+ *
+ * @return array
+ */
+ public function getReference(): array {
+ return [
+ "title" => $this->title,
+ "uri" => $this->uri,
+ "ref" => $this->ref,
+ "chunkContents" => $this->chunkContents,
+ "seq" => $this->seq,
+ "original_seq" => $this->original_seq,
+ "locations" => $this->locations,
+ "is_result" => $this->is_result,
+ ];
+ }
+
+ /**
+ * Adds content to a specific page chunk.
+ *
+ * @param string $content The content to be added.
+ * @param string $pageIdentifier The identifier of the page where the content will be added.
+ *
+ * @return AIsearchReference Returns the instance of the AIsearchReference for method chaining.
+ */
+ public function addChunkContent(string $content, string $pageIdentifier): AiSearchReference {
+ $this->chunkContents[] = [
+ "content" => $content,
+ "pageIdentifier" => $pageIdentifier,
+ ];
+ return $this;
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchReferenceCollection.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchReferenceCollection.php
new file mode 100644
index 0000000000..acbbd271db
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchReferenceCollection.php
@@ -0,0 +1,53 @@
+references ?? []);
+ }
+ $this->references[$key] = $reference->getReference();
+ return $this;
+ }
+
+ /**
+ * Get all results as an array of AISearchReference objects.
+ *
+ * @return array
+ */
+ public function getReferences(): array {
+ return $this->references;
+ }
+
+ /**
+ * Returns the number of AISearchReference objects in the collection.
+ * @return int
+ */
+ public function count(): int {
+ return count($this->references);
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchRequest.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchRequest.php
new file mode 100644
index 0000000000..eebcdb5581
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchRequest.php
@@ -0,0 +1,109 @@
+search_text = trim($search_text);
+ }
+ $this->result_count = $result_count;
+ if (!empty($result_template)) {
+ $this->result_template = $result_template;
+ }
+ }
+
+ public function addHistory(AiSearchResponse $search): AiSearchRequest {
+ $this->history[] = $search;
+ return $this;
+ }
+ public function getHistory(): array {
+ return $this->history;
+ }
+
+ public function set(string $key, mixed $value): AiSearchRequest {
+ if ($key == "session_id") {
+ $this->load($value);
+ }
+ else {
+ parent::set($key, $value);
+ }
+ return $this;
+ }
+
+ public function getId(): string {
+ return $this->session_id;
+ }
+
+ /**
+ * Save the conversation history so it can be retrieved later.
+ *
+ * @return void
+ */
+ public function save(): void {
+ Drupal::service("keyvalue.expirable")
+ ->get("bos_aisearch")
+ ->setWithExpire($this->session_id, $this->getHistory(), 300);
+ }
+
+ /**
+ * Loads a previous conversation history from key:value store.
+ *
+ * @param string $id Conversation ID to retrieve
+ *
+ * @return \Drupal\bos_search\AiSearchRequest
+ */
+ protected function load(string $id = ""): AiSearchRequest {
+ if (!empty($id)) {
+ // use the supplied session_id rather than the one set in the class.
+ $this->session_id = $id;
+ }
+
+ if (!empty($this->session_id)) {
+ // If we have a saved conversation, load it up.
+ if ($history = Drupal::service("keyvalue.expirable")
+ ->get("bos_aisearch")
+ ->get($this->session_id) ?? FALSE) {
+ $this->history = $history;
+ }
+
+ }
+ return $this;
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResponse.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResponse.php
new file mode 100644
index 0000000000..bbab673787
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResponse.php
@@ -0,0 +1,215 @@
+summary = $summary;
+ $this->session_id = $session_id;
+ $this->search = $search;
+ $this->citations = new AiSearchCitationCollection();
+ $this->references = new AiSearchReferenceCollection();
+ $this->search_results = new AiSearchResultCollection();
+ $this->search_results->setMaxResults($search->get("result_count"));
+ }
+
+ /**
+ *
+ *
+ * @param \Drupal\bos_search\AiSearchResult $result
+ *
+ * @return $this
+ */
+ public function addResult(AiSearchResult $result): AiSearchResponse {
+ $this->search_results->addResult($result);
+ return $this;
+ }
+
+ public function addCitation(AiSearchCitation $citation, int $key = NULL): AiSearchResponse {
+ $this->citations->addCitation($citation, $key);
+ return $this;
+ }
+
+ public function updateCitation(AiSearchCitation $citation, int $key = NULL): AiSearchCitation {
+ $this->citations->updateCitation($citation, $key);
+ return $this;
+ }
+
+ public function addReference(AiSearchReference $reference, $key = NULL): AiSearchResponse {
+ $this->references->addReference($reference, $key);
+ return $this;
+ }
+
+ public function setReferenceId(int $oldReferenceId, int $newReferenceId): void {
+ foreach ($this->citations as $citation) {
+
+ }
+ }
+
+ public function getAll(): array {
+ return [
+ "ai_answer" => $this->summary,
+ "no_results" => $this->no_results,
+ "violations" => $this->violations,
+ "session_id" => $this->session_id,
+ "results" => $this->search_results->getResults()
+ ];
+ }
+
+ public function getMetaData():array {
+ return $this->metadata;
+ }
+
+ public function getResults():array {
+ return $this->search_results->getResults();
+ }
+
+ public function getResultsCollection():AiSearchResultCollection {
+ return $this->search_results;
+ }
+
+ public function getCitationsCollection():AiSearchCitationCollection {
+ return $this->citations;
+ }
+
+ public function getReferences():array {
+ return $this->references->getReferences();
+ }
+
+ public function getCitations():array {
+ return $this->citations->getCitations();
+ }
+
+ public function build(): string {
+
+ $preset = $this->search->get("preset") ?? [];
+
+ $render_array = ['#theme' => 'results__' . $preset["searchform"]["theme"]];
+
+ $response = $this->getAll();
+
+ if ($response["no_results"] == 0 && empty($response["violations"]) && $this->search_results) {
+ // A summary and optionally citations and results have been returned
+ // from the AI Model.
+ $render_array += [
+ '#id' => $this->search->getId(),
+ '#response' => $this->summary,
+ '#feedback' => [
+ "#theme" => "aisearch_feedback",
+ "#thumbsup" => TRUE,
+ "#thumbsdown" => TRUE,
+ ],
+ '#metadata' => $preset["results"]["metadata"] ? ($this->metadata ?? NULL) : NULL,
+ ];
+
+ // Add in the search Result items.
+ foreach ($this->search_results->getResults() as $result) {
+ $render_array["#items"][] = $result->getResult();
+ }
+
+ // Add in the Citation References.
+ if ($preset["results"]["citations"]) {
+ foreach ($this->references->getReferences() as $citation) {
+ $render_array['#citations'][] = $citation;
+ }
+ }
+
+ if (!$preset["results"]["summary"] ?? TRUE) {
+ // If we are supressing the summary, then also supress the citations.
+ $render_array["#content"] = NULL;
+ $render_array["#citations"] = NULL;
+ }
+
+ if (!$preset["results"]["feedback"] ?? TRUE) {
+ // If we are supressing feedback.
+ $render_array["#feedback"] = NULL;
+ }
+ }
+ elseif (!empty($response["violations"])) {
+ // There were violations.
+ $render_array += [
+ '#items' => $this->search_results->getResults() ?? [],
+ '#content' => $this->search->get("search_text"),
+ '#id' => $this->search->getId(),
+ '#response' => $preset["results"]["violations_text"] ?? "Non-conforming Query",
+ '#metadata' => $preset["results"]["metadata"] ? ($this->metadata ?? NULL) : NULL,
+ ];
+
+ if (!$preset["results"]["summary"] ?? TRUE) {
+ // If we are supressing the summary, then also supress the citations.
+ $render_array["#content"] = NULL;
+ $render_array["#citations"] = NULL;
+ }
+
+ if (!$preset["results"]["feedback"] ?? TRUE) {
+ // If we are supressing feedback.
+ $render_array["#feedback"] = NULL;
+ }
+ }
+ else {
+ // No results message was returned from the AI.
+ $render_array += [
+ '#id' => $this->search->getId(),
+ '#feedback' => [
+ "#theme" => "aisearch_feedback",
+ "#thumbsup" => TRUE,
+ "#thumbsdown" => TRUE,
+ ],
+ '#response' => $preset["results"]["no_result_text"],
+ '#no_results' => $this->no_results,
+ '#metadata' => $preset["results"]["metadata"] ? ($this->metadata ?? NULL) : NULL,
+ ];
+ // Add in the search Result items.
+ foreach ($this->search_results->getResults() as $result) {
+ $render_array["#items"][] = $result->getResult();
+ }
+
+ }
+ // Allow to override the theme template.
+ if (!empty($this->search->get("result_template"))) {
+ $render_array['#theme'] = $this->search->get("result_template");
+ }
+ return \Drupal::service("renderer")->render($render_array);
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResult.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResult.php
new file mode 100644
index 0000000000..0bf7016572
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResult.php
@@ -0,0 +1,72 @@
+title = $title;
+ $this->link = $link;
+ $this->summary = $summary;
+ }
+
+ /**
+ * Returns an array with all the properties of this class.
+ *
+ * @return array
+ */
+ public function getResult(): array {
+ return [
+ "content" => $this->content,
+ "description" => $this->description,
+ "id" => $this->id,
+ "link" => $this->link,
+ "link_title" => $this->link_title,
+ "ref" => $this->ref,
+ "summary" => $this->summary,
+ "title" => $this->title,
+ "nid" => $this->nid
+ ];
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResultCollection.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResultCollection.php
new file mode 100644
index 0000000000..61989abdd5
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResultCollection.php
@@ -0,0 +1,77 @@
+max_count = $max_count;
+ }
+ $this->results = [];
+ }
+
+ /**
+ * Add a search result to the collection.
+ *
+ * @param \Drupal\bos_search\AiSearchResult $result
+ *
+ * @return $this
+ */
+ public function addResult(AiSearchResult $result): AiSearchResultCollection {
+ if ($this->max_count === 0 || $this->count() < $this->max_count) {
+ // Only add the requested number of results.
+ $this->results[] = $result;
+ }
+ return $this;
+ }
+
+ public function updateResult($key, AiSearchResult $result):AiSearchResultCollection {
+ $this->results[$key] = $result;
+ return $this;
+ }
+
+ /**
+ * Get all results as an array of AiSearchResult objects.
+ *
+ * @return array
+ */
+ public function getResults(): array {
+ return $this->results;
+ }
+
+ /**
+ * Returns the number of AiSearchResult objects in the collection.
+ * @return int
+ */
+ public function count(): int {
+ return count($this->results);
+ }
+
+ /**
+ * Sets the maximum number of AiSearchResults objects allowed in the collection.
+ * @param $count
+ *
+ * @return void
+ */
+ public function setMaxResults(int $count):void {
+ $this->max_count = $count;
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/AiSearch/AiSearchPluginManager.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/AiSearch/AiSearchPluginManager.php
new file mode 100644
index 0000000000..9eb0153ad1
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/AiSearch/AiSearchPluginManager.php
@@ -0,0 +1,28 @@
+alterInfo('aisearch_info');
+ $this->setCacheBackend($cache_backend, 'aisearch_plugins');
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchButtonBlock.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchButtonBlock.php
new file mode 100644
index 0000000000..f1aa78ffde
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchButtonBlock.php
@@ -0,0 +1,157 @@
+ "Search",
+ "search_button_css" => "",
+ "aisearch_config_preset" => ""
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function blockForm($form, FormStateInterface $form_state): array {
+ $presets = AiSearch::getPresets();
+ $form = parent::blockForm($form, $form_state);
+ $form['button'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Search Button Display'),
+ '#description' => $this->t('Settings for the button used to launch the search form.'),
+ '#description_display' => 'before',
+ 'search_button_title' => [
+ '#type' => 'textfield',
+ '#title' => $this->t('Button Text'),
+ '#description' => $this->t('Enter the text to appear on the search button.'),
+ '#default_value' => $this->configuration['search_button_title'] ?? "",
+ ],
+ 'search_button_css' => [
+ '#type' => 'textfield',
+ '#title' => $this->t('Search Button Custom css'),
+ '#description' => $this->t('Add any additional css classes to the button'),
+ '#default_value' => $this->configuration['search_button_css'] ?? "",
+ ],
+ 'search_block_text' => [
+ '#type' => 'textarea',
+ '#title' => $this->t('Search Block Body Text'),
+ '#description' => $this->t('Enter the body text to appear alongside the search button. Can be left blank.'),
+ '#default_value' => $this->configuration['search_block_text'] ?? "",
+ ],
+ ];
+ $form['display'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Search Form Display'),
+ '#description' => $this->t('Settings which control the way the search form is presented to the user.'),
+ '#description_display' => 'before',
+ 'aisearch_config_display' => [
+ '#type' => 'radios',
+ '#title' => $this->t('Form Type'),
+ '#options' => [
+ 0 => 'Modal (form will show in a popup window)',
+ 1 => 'Block (form will display in a block on a page)',
+ ],
+ '#description' => $this->t('Select the display method for the search form.'),
+ '#default_value' => $this->configuration['aisearch_config_display'] ?? "",
+ ],
+ 'aisearch_config_preset' => [
+ '#type' => 'select',
+ '#title' => $this->t('AI-Enabled Search Preset'),
+ '#options' => $presets,
+ '#description' => $this->t('Select the AI Model (and settings) for the Modal Search Form.'),
+ '#default_value' => $this->configuration['aisearch_config_preset'] ?? "",
+ '#states' => [
+ 'visible' => [
+ ':input[name="settings[display][aisearch_config_display]"]' => ['value' => '0'],
+ ],
+ ],
+ ],
+ 'aisearch_config_searchpage' => [
+ '#type' => 'textfield',
+ '#title' => $this->t('Host Form Page'),
+ '#autocomplete_route_name' => 'bos_search.autocomplete_nodes',
+ '#description' => $this->t('Please select the page which contains the search block.'),
+ '#default_value' => $this->configuration['aisearch_config_searchpage'] ?? "",
+ '#states' => [
+ 'visible' => [
+ ':input[name="settings[display][aisearch_config_display]"]' => ['value' => '1'],
+ ],
+ ],
+ ],
+ ];
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function blockSubmit($form, FormStateInterface $form_state): void {
+ parent::blockSubmit($form, $form_state);
+ $this->configuration['aisearch_config_preset'] = $form_state->getValue('display')['aisearch_config_preset'];
+ $this->configuration['aisearch_config_display'] = $form_state->getValue('display')['aisearch_config_display'];
+ $this->configuration['aisearch_config_searchpage'] = $form_state->getValue('display')['aisearch_config_searchpage'];
+ $this->configuration['search_button_title'] = $form_state->getValue('button')['search_button_title'];
+ $this->configuration['search_block_text'] = $form_state->getValue('button')['search_block_text'];
+ $this->configuration['search_button_css'] = $form_state->getValue('button')['search_button_css'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function build(): array {
+
+ if ($this->configuration["aisearch_config_display"] === "0") {
+ $url = Url::fromRoute('bos_search.open_AISearchForm');
+ }
+ else {
+ $url = $this->configuration["aisearch_config_searchpage"];
+ }
+
+ $config = AiSearch::getPresetValues($this->configuration["aisearch_config_preset"]);
+ $custom_theme_path = "/modules/custom/bos_components/modules/bos_search/templates/presets/{$config['searchform']['theme']}";
+
+ return [
+ '#theme' => 'aisearch_button',
+ '#attached' => [
+ "library" => ["bos_search/dynamic-loader"],
+ "drupalSettings" => [
+ "bos_search" => [
+ 'dynamic_script' => "$custom_theme_path/js/preset.js",
+ 'dynamic_style' => "$custom_theme_path/css/preset.css",
+ ]
+ ],
+ ],
+ '#search_form_url' => $url,
+ '#button_title' => $this->configuration["search_button_title"],
+ '#button_css' => $this->configuration["search_button_css"],
+ '#preset' => $this->configuration["aisearch_config_preset"],
+ '#body' => $this->configuration["search_block_text"],
+ '#display' => $this->configuration["aisearch_config_display"] == "0" ? "modal" : "block",
+ ];
+
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchFormBlock.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchFormBlock.php
new file mode 100644
index 0000000000..a2c2cb3ed2
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchFormBlock.php
@@ -0,0 +1,78 @@
+ "AI Search of Site",
+ "aisearch_config_preset" => ""
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function blockForm($form, FormStateInterface $form_state) {
+ $presets = AiSearch::getPresets();
+ $form = parent::blockForm($form, $form_state);
+ $form['preset'] = [
+ '#type' => 'fieldset',
+ '#title' => 'Search Block Preset',
+ '#description' => $this->t('Please provide a preset to be used by this form block.'),
+ '#description_display' => 'before',
+ 'aisearch_config_preset' => [
+ '#type' => 'select',
+ '#options' => $presets,
+ '#description' => $this->t('This defines the AI Model (and settings) that the Search Form will utilise.
Presets are defined at
admin/config/system/boston/aisearch '),
+ '#default_value' => $this->configuration['aisearch_config_preset'] ?? "",
+ ],
+ ];
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function blockSubmit($form, FormStateInterface $form_state) {
+ parent::blockSubmit($form, $form_state);
+ $this->configuration['aisearch_config_preset'] = $form_state->getValue('preset')['aisearch_config_preset'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function build() {
+ $preset = \Drupal::request()->query->get('preset') ?: ($this->configuration["aisearch_config_preset"] ?: AiSearch::getPreset());
+ $params = [
+ "preset" => $preset,
+ ];
+ return [
+ [
+ '#lazy_builder' => ['bos_search.callbacks:renderSearchForm', $params ],
+ '#create_placeholder' => TRUE,
+ ],
+ ];
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Twig/CustomFiltersExtension.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Twig/CustomFiltersExtension.php
new file mode 100644
index 0000000000..1e9ae45765
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Twig/CustomFiltersExtension.php
@@ -0,0 +1,38 @@
+ 0);
+ }
+
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/card.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/card.html.twig
new file mode 100644
index 0000000000..05f63f2ca1
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/card.html.twig
@@ -0,0 +1,73 @@
+{#
+/**
+ * @file components/card.html.twig
+ * Theme for a grid of cards
+ * @see https://patterns.boston.gov/components/detail/quote-card--default.html
+ *
+ * Variables which can be used are:
+ * attributes - an array of attributes for the outside wrapper around the
+ * card component.
+ * quote_text - The actual quote body.
+ * picture - A URL for an image to be shown above the persons name.
+ * show_quotes - boolean flag for whether quotes image should be shown.
+ * Note: defaults to FALSE if picture has a value.
+ * link - A URL link which apoears above the persons name.
+ * link_text - The text for the link.
+ * location - A location printed below the persons name.
+*/
+#}
+
+{%
+ set classes = [
+ "cd",
+ "m-t500",
+ "g--4",
+ "g--4--sl",
+ "card",
+ "card-wrapper"
+ ]
+%}
+{%
+ set title_classes = [
+ "cd-t",
+ "card-title"
+ ]
+%}
+{%
+ set subtitle_classes = [
+ "t--upper",
+ "t--subtitle",
+ "cd-st",
+ "card-subtitle"
+ ]
+%}
+{%
+ set content_classes = [
+ "cd-d",
+ "card-content"
+]
+%}
+
+{{ attach_library("bos_search/component.card") }}
+
+
+
+ {% if image %}
+
+ {% endif %}
+
+
+
+ {% if title %}
+
{{ title }}
+ {% endif %}
+
+ {% if subtitle %}
+
{{ subtitle }}
+ {% endif %}
+
+
{{ content }}
+
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/grid-of-cards.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/grid-of-cards.html.twig
new file mode 100644
index 0000000000..252e813c8b
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/grid-of-cards.html.twig
@@ -0,0 +1,43 @@
+{#
+ /**
+ * @file components/grid-of-cards.html.twig
+ * Theme for a grid of cards
+ * @see https://patterns.boston.gov/components/detail/card--grid.html
+ *
+ * Variables which can be used are:
+ * attributes - an array of attributes for the outside wrapper around the
+ * grid component.
+ * title - a title for the grid.
+ * attributes - an array of attributes for the title.
+ * cards - an array of cards.
+ */
+#}
+{%
+ set classes = [
+ "b--w",
+ "b--fw",
+ "grid-of-cards"
+ ]
+%}
+{%
+ set title_classes = [
+ "txt-l",
+ "goc-title"
+ ]
+%}
+
+{{ attach_library("bos_search/component.grid_of_cards") }}
+
+
+
+
+ {% if title %}
+
{{ title }}
+ {% endif %}
+
+
+ {{ cards }}
+
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/quote-card.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/quote-card.html.twig
new file mode 100644
index 0000000000..4a52ad107b
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/quote-card.html.twig
@@ -0,0 +1,64 @@
+{#
+/**
+ * @file components/quote-card.html.twig
+ * Theme for a grid of cards
+ * @see https://patterns.boston.gov/components/detail/quote-card--default.html
+ *
+ * Variables which can be used are:
+ * attributes - an array of attributes for the outside wrapper around the
+ * card component.
+ * quote_text - The actual quote body.
+ * picture - A URL for an image to be shown above the persons name.
+ * show_quotes - boolean flag for whether quotes image should be shown.
+ * Note: defaults to FALSE if picture has a value.
+ * link - A URL link which apoears above the persons name.
+ * link_text - The text for the link.
+ * location - A location printed below the persons name.
+*/
+#}
+
+{%
+ set classes = [
+ "goq",
+ "g--3",
+ "g--3--sl",
+ "m-t500",
+ "m-b300"
+ ]
+%}
+
+{{ attach_library("bos_search/component.quote_card") }}
+
+
+
+
"{{ content }}"
+
+
+
+ {% if picture or show_quotes %}
+
+ {% if not picture and show_quotes %}
+
+ {% endif %}
+ {% if picture %}
+
+ {% endif %}
+ {% if link %}
+
{{ link_text }}
+ {% endif %}
+
+ {% endif %}
+
+ {% if person or location %}
+
+ {% if person %}
+
{{ person }}
+ {% endif %}
+ {% if location %}
+
{{ location }}
+ {% endif %}
+
+ {% endif %}
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/search-bar.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/search-bar.html.twig
new file mode 100644
index 0000000000..dedcf7e099
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/search-bar.html.twig
@@ -0,0 +1,68 @@
+{#
+/**
+ * @file
+ * Theme override for an 'input' #type form element.
+ *
+ * Available variables:
+ * - attributes: A list of HTML attributes for the input element.
+ * - children: Optional additional rendered elements.
+ *
+ * @see template_preprocess_input()
+ */
+#}
+{%
+ set wrapper_classes = [
+ "search-bar-wrapper"
+ ]
+%}
+{%
+ set classes = [
+ "search-bar"
+ ]
+%}
+{%
+ set title_classes = [
+ "search-bar-title",
+ "txt-l"
+ ]
+%}
+
+{{ attach_library("bos_search/component.search_bar") }}
+
+
+
+ {% if title %}
+
+ {% if title_prefix %}
+ {{ title_prefix }}
+ {% endif %}
+
+
{{ title }}
+
+ {% if title_suffix %}
+ {{ title_suffix }}
+ {% endif %}
+
+ {% endif %}
+
+
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/form-element--webform-checkbox.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/form-element--webform-checkbox.html.twig
new file mode 100644
index 0000000000..a13ff8ca43
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/form-element--webform-checkbox.html.twig
@@ -0,0 +1,95 @@
+{#
+/**
+ * @file
+ * Theme override for a form element.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the containing element.
+ * - errors: (optional) Any errors for this form element, may not be set.
+ * - prefix: (optional) The form element prefix, may not be set.
+ * - suffix: (optional) The form element suffix, may not be set.
+ * - required: The required marker, or empty if the associated form element is
+ * not required.
+ * - type: The type of the element.
+ * - name: The name of the element.
+ * - label: A rendered label element.
+ * - label_display: Label display setting. It can have these values:
+ * - before: The label is output before the element. This is the default.
+ * The label includes the #title and the required marker, if #required.
+ * - after: The label is output after the element. For example, this is used
+ * for radio and checkbox #type elements. If the #title is empty but the
+ * field is #required, the label will contain only the required marker.
+ * - invisible: Labels are critical for screen readers to enable them to
+ * properly navigate through forms but can be visually distracting. This
+ * property hides the label for everyone except screen readers.
+ * - attribute: Set the title attribute on the element to create a tooltip but
+ * output no label element. This is supported only for checkboxes and radios
+ * in \Drupal\Core\Render\Element\CompositeFormElementTrait::preRenderCompositeFormElement().
+ * It is used where a visual label is not needed, such as a table of
+ * checkboxes where the row and column provide the context. The tooltip will
+ * include the title and required marker.
+ * - description: (optional) A list of description properties containing:
+ * - content: A description of the form element, may not be set.
+ * - attributes: (optional) A list of HTML attributes to apply to the
+ * description content wrapper. Will only be set when description is set.
+ * - description_display: Description display setting. It can have these values:
+ * - before: The description is output before the element.
+ * - after: The description is output after the element. This is the default
+ * value.
+ * - invisible: The description is output after the element, hidden visually
+ * but available to screen readers.
+ * - disabled: True if the element is disabled.
+ * - title_display: Title display setting.
+ *
+ * @see template_preprocess_form_element()
+ */
+#}
+{%
+ set classes = [
+ 'js-form-item',
+ 'form-item',
+ 'form-type-' ~ type|clean_class,
+ 'js-form-type-' ~ type|clean_class,
+ 'form-item-' ~ name|clean_class,
+ 'js-form-item-' ~ name|clean_class,
+ title_display not in ['after', 'before'] ? 'form-no-label',
+ disabled == 'disabled' ? 'form-disabled',
+ errors ? 'form-item--error',
+ ]
+%}
+{%
+ set description_classes = [
+ 'description',
+ description_display == 'invisible' ? 'visually-hidden',
+ ]
+%}
+
+ {% if label_display in ['before', 'invisible'] %}
+ {{ label }}
+ {% endif %}
+ {% if prefix is not empty %}
+
{{ prefix }}
+ {% endif %}
+ {% if description_display == 'before' and description.content %}
+
+ {{ description.content }}
+
+ {% endif %}
+ {{ children }}
+ {% if suffix is not empty %}
+
{{ suffix }}
+ {% endif %}
+ {% if label_display == 'after' %}
+ {{ label }}
+ {% endif %}
+ {% if errors %}
+
+ {{ errors }}
+
+ {% endif %}
+ {% if description_display in ['after', 'invisible'] and description.content %}
+
+ {{ description.content }}
+
+ {% endif %}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--button.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--button.html.twig
new file mode 100644
index 0000000000..02a3a3c319
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--button.html.twig
@@ -0,0 +1,40 @@
+{#
+/**
+ * @file
+ * Theme for the AI Search Button Block.
+ *
+ * Available variables:
+ * - plugin_id: The ID of the block implementation.
+ * - label: The configured label of the block if visible.
+ * - configuration: A list of the block's configuration values.
+ * - label: The configured label for the block.
+ * - label_display: The display settings for the label.
+ * - provider: The module or other provider that provided this block plugin.
+ * - Block plugin specific settings will also be stored here.
+ * - content: The content of this block.
+ * - attributes: array of HTML attributes populated by modules, intended to
+ * be added to the main container tag of this template.
+ * - id: A valid HTML ID and guaranteed unique.
+ * - title_attributes: Same as attributes, except applied to the main title
+ * tag that appears in the template.
+ * - title_prefix: Additional output populated by modules, intended to be
+ * displayed in front of the main title tag that appears in the template.
+ * - title_suffix: Additional output populated by modules, intended to be
+ * displayed after the main title tag that appears in the template.
+ *
+ * @see template_preprocess_block()
+ */
+#}
+{% set classes = ["block", "aienabledsearchbutton-inner-wrapper", "b-c"] %}
+
+
+ {{ title_prefix }}
+ {% if label %}
+
{{ label }}.
+ {% endif %}
+ {{ title_suffix }}
+ {% block content %}
+ {{ content }} {# @see aisearch-button.html.twig #}
+ {% endblock %}
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--form.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--form.html.twig
new file mode 100644
index 0000000000..295be5ae58
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--form.html.twig
@@ -0,0 +1,38 @@
+{#
+/**
+ * @file
+ * Theme for the AI Search Form Block.
+ *
+ * Available variables:
+ * - plugin_id: The ID of the block implementation.
+ * - label: The configured label of the block if visible.
+ * - configuration: A list of the block's configuration values.
+ * - label: The configured label for the block.
+ * - label_display: The display settings for the label.
+ * - provider: The module or other provider that provided this block plugin.
+ * - Block plugin specific settings will also be stored here.
+ * - content: The content of this block.
+ * - attributes: array of HTML attributes populated by modules, intended to
+ * be added to the main container tag of this template.
+ * - id: A valid HTML ID and guaranteed unique.
+ * - title_attributes: Same as attributes, except applied to the main title
+ * tag that appears in the template.
+ * - title_prefix: Additional output populated by modules, intended to be
+ * displayed in front of the main title tag that appears in the template.
+ * - title_suffix: Additional output populated by modules, intended to be
+ * displayed after the main title tag that appears in the template.
+ *
+ * @see template_preprocess_block()
+ */
+#}
+{% set classes = ['block','block-aisearchform','aienabledsearchform'] %}
+
+ {{ title_prefix }}
+ {% if label %}
+
{{ label }}
+ {% endif %}
+ {{ title_suffix }}
+ {% block content %}
+ {{ content }}
+ {% endblock %}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/checkboxes.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/checkboxes.html.twig
new file mode 100644
index 0000000000..196dc6f96e
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/checkboxes.html.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Theme override for a 'checkboxes' #type form element.
+ *
+ * Available variables
+ * - attributes: A list of HTML attributes for the wrapper element.
+ * - children: The rendered checkboxes.
+ *
+ * @see template_preprocess_checkboxes()
+ */
+#}
+
{{ children }}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/container.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/container.html.twig
new file mode 100644
index 0000000000..0da6c388d0
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/container.html.twig
@@ -0,0 +1,28 @@
+{#
+/**
+ * @file
+ * Theme override of a container used to wrap child elements.
+ *
+ * Used for grouped form items. Can also be used as a theme wrapper for any
+ * renderable element, to surround it with a
and HTML attributes.
+ * See \Drupal\Core\Render\Element\RenderElement for more
+ * information on the #theme_wrappers render array property, and
+ * \Drupal\Core\Render\Element\container for usage of the container render
+ * element.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the containing element.
+ * - children: The rendered child elements of the container.
+ * - has_parent: A flag to indicate that the container has one or more parent
+ containers.
+ *
+ * @see template_preprocess_container()
+ */
+#}
+{%
+ set classes = [
+ has_parent ? 'js-form-wrapper',
+ has_parent ? 'form-wrapper',
+ ]
+%}
+
{{ children }}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/css/preset.css b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/css/preset.css
new file mode 100644
index 0000000000..bca7190fac
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/css/preset.css
@@ -0,0 +1,164 @@
+/* Locks the Searchbar to the bottom of the wrapper */
+.aienabledsearchform.aisearch-modal-form div.search-bar-wrapper,
+.aienabledsearchform.block-aisearchform div.search-bar-wrapper {
+ position: sticky;
+ bottom: 0;
+ background-color: white;
+ display: flex;
+ padding-top: 4px;
+}
+
+.bos-search-aisearchform .search-request {
+ border: none;
+ text-align: left;
+}
+
+ /* Vertically locates the welcome area in the center of the window */
+.aienabledsearchform #bos-search-aisearchform #edit-searchresults #edit-welcome {
+}
+.aienabledsearchform .search-citations-wrapper {
+ margin-bottom: 24px;
+}
+.aienabledsearchform .search-citations-wrapper.hide-citation {
+ margin-bottom: 0;
+}
+.aienabledsearchform .search-citations-drawer .search-citation-more {
+ display:none;
+}
+.aienabledsearchform .search-citations-drawer {
+ margin: 0;
+ min-height: 44px;
+}
+
+.aienabledsearchform .search-citations-drawer label {
+ padding: 8px 53px 8px 16px;
+ width: 100%;
+ font-size: 1em;
+ line-height: 28px;
+ background-color: #f2f2f2;
+ cursor: pointer;
+}
+.aienabledsearchform .search-citations-drawer label:hover {
+ background-color: #288be4;
+ color: #fff;
+}
+.aienabledsearchform .search-citations-drawer label:active {
+ background-color: #091f2f;
+ color: #fff;
+}
+.aienabledsearchform .search-citations-drawer .dr-ic {
+ right: 16px;
+ top: 22px;
+ height: 20px;
+ margin: 0;
+ width: 20px;
+ padding-top: 0px;
+ padding-right: 0px;
+ /* width: 24px; */
+}
+.aienabledsearchform .search-citations-drawer .dr-tr:checked~.dr-h .dr-ic {
+ margin-top: 0;
+}
+.aienabledsearchform .search-citations-drawer .dr-ic svg {
+}
+.aienabledsearchform .search-citations-drawer .dr-t {
+ line-height: 48px;
+}
+.block .aienabledsearchbutton {
+ display: flex;
+ flex-flow: column-reverse;
+}
+.block .aienabledsearchbutton .button {
+}
+.block .aienabledsearchbutton legend {
+}
+.block-aisearchform .modal-close-wrapper {
+ position: fixed;
+ width:100%;
+
+}
+.block-aisearchform .ai-form-reset {
+}
+.block-aisearchform .ai-reset-button:after {
+ content: "New Search";
+}
+.block-aisearchform .modal-close {
+ display: none;
+}
+.goc-grid .card-wrapper div.card-content-wrapper {
+ text-align: left;
+}
+.bos-search-aisearchform .search-request-wrapper:first-of-type {
+ margin-top: 100px;
+}
+.bos-search-aisearchform .search-metadata-set-wrapper table {
+ border-collapse: initial;
+ /* border-spacing: 8px; */
+}
+.bos-search-aisearchform .search-metadata-title {
+ vertical-align: middle;
+ width: 33%;
+ max-width: 40%;
+ font-weight: bold;
+ overflow-wrap: anywhere;
+ text-wrap: balance;
+ padding: 8px 8px;
+}
+.bos-search-aisearchform .search-metadata-value {
+ vertical-align: middle;
+ overflow-wrap: anywhere;
+ padding: 8px;
+}
+.bos-search-aisearchform .search-metadata-wrapper {
+ background-color: #f3f3f3;
+}
+.bos-search-aisearchform .search-metadata-set-wrapper tbody tr:nth-child(even) td {
+ background-color: #f3f3f3;
+}
+/**
+ * DESKTOP adjustments
+ * This component considers mobile upto window width of 480px
+ */
+
+@media screen and (min-width: 480px) {
+ .aienabledsearchform.aisearch-modal-form div.search-bar-wrapper,
+ .aienabledsearchform.block-aisearchform div.search-bar-wrapper {
+ bottom: 0;
+ margin-left: -12px;
+ width: calc(100% + 12px);
+ }
+
+ .aienabledsearchform .search-bar-input-wrapper {
+ margin: 5px auto 0 12px;
+ }
+
+ .aienabledsearchform .search-citations-drawer.show-more .search-citation-more-wrapper {
+ background-color: #f9f9f9;
+ padding: 16px;
+ }
+ .aienabledsearchform #bos-search-aisearchform #edit-searchresults #edit-welcome {}
+ .aienabledsearchform .search-citations-drawer.show-more .search-citation-more {
+ display:block;
+ color: #1871bd;
+ background-color: #f2f2f2;
+ text-decoration: none;
+ font-size: calc(1em + 2px);
+ font-family: Montserrat, Arial, sans-serif;
+ margin-top: -16px;
+ border-radius: 2px;
+ }
+ .aienabledsearchform .search-citations-drawer.show-more .search-citation-more:hover,
+ .aienabledsearchform .search-citations-drawer.show-more .search-citation-more:focus,
+ .aienabledsearchform .search-citations-drawer.show-more .search-citation-more:active {
+ background-color: rgba(40, 139, 228, 0.10);
+ }
+ .block-aisearchform .modal-close-wrapper {
+ position: fixed;
+ width: 100%;
+ z-index: 10;
+ height: 80px;
+ }
+ .adminimal-admin-toolbar .block-aisearchform .modal-close-wrapper {
+ top: 120px;
+ }
+}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/details.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/details.html.twig
new file mode 100644
index 0000000000..1987995927
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/details.html.twig
@@ -0,0 +1,38 @@
+{#
+/**
+ * @file
+ * Theme override for a details element.
+ *
+ * Available variables
+ * - attributes: A list of HTML attributes for the details element.
+ * - errors: (optional) Any errors for this details element, may not be set.
+ * - title: (optional) The title of the element, may not be set.
+ * - summary_attributes: A list of HTML attributes for the summary element.
+ * - description: (optional) The description of the element, may not be set.
+ * - children: (optional) The children of the element, may not be set.
+ * - value: (optional) The value of the element, may not be set.
+ *
+ * @see template_preprocess_details()
+ */
+#}
+
+ {%
+ set summary_classes = [
+ required ? 'js-form-required',
+ required ? 'form-required',
+ ]
+ %}
+ {%- if title -%}
+ {{ title }}
+ {%- endif -%}
+
+ {% if errors %}
+
+ {{ errors }}
+
+ {% endif %}
+
+ {{ description }}
+ {{ children }}
+ {{ value }}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/disclaimer.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/disclaimer.html.twig
new file mode 100644
index 0000000000..6f5fdc30eb
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/disclaimer.html.twig
@@ -0,0 +1,5 @@
+
+
Disclaimer
+
{{ children.notice }}
+ {{ children.actions }}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/fieldset.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/fieldset.html.twig
new file mode 100644
index 0000000000..b23402cc0d
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/fieldset.html.twig
@@ -0,0 +1,74 @@
+{#
+/**
+ * @file
+ * Theme override for a fieldset element and its children.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the
element.
+ * - errors: (optional) Any errors for this element, may not be set.
+ * - required: Boolean indicating whether the element is required.
+ * - legend: The element containing the following properties:
+ * - title: Title of the , intended for use as the text
+ of the .
+ * - attributes: HTML attributes to apply to the element.
+ * - description: The description element containing the following properties:
+ * - content: The description content of the .
+ * - attributes: HTML attributes to apply to the description container.
+ * - description_display: Description display setting. It can have these values:
+ * - before: The description is output before the element.
+ * - after: The description is output after the element (default).
+ * - invisible: The description is output after the element, hidden visually
+ * but available to screen readers.
+ * - children: The rendered child elements of the .
+ * - prefix: The content to add before the children.
+ * - suffix: The content to add after the children.
+ *
+ * @see template_preprocess_fieldset()
+ */
+#}
+{%
+ set classes = [
+ 'js-form-item',
+ 'js-form-wrapper',
+ 'form-item',
+ 'form-wrapper',
+ 'ais-fieldset'
+ ]
+%}
+
+
+ {% if legend.title %}
+ {%
+ set legend_span_classes = [
+ 'fieldset-legend',
+ required ? 'js-form-required',
+ required ? 'form-required',
+ ]
+ %}
+ {# Always wrap fieldset legends in a for CSS positioning. #}
+
+ {{ legend.title }}
+
+ {% endif %}
+
+
+ {% if description_display == 'before' and description.content %}
+
{{ description.content }}
+ {% endif %}
+ {% if errors %}
+
+ {{ errors }}
+
+ {% endif %}
+ {% if prefix %}
+
{{ prefix }}
+ {% endif %}
+ {{ element }}
+ {% if suffix %}
+
{{ suffix }}
+ {% endif %}
+ {% if description_display in ['after', 'invisible'] and description.content %}
+
{{ description.content }}
+ {% endif %}
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element-label.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element-label.html.twig
new file mode 100644
index 0000000000..91a051a3fd
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element-label.html.twig
@@ -0,0 +1,26 @@
+{#
+/**
+ * @file
+ * Theme override for a form element label.
+ *
+ * Available variables:
+ * - title: The label's text.
+ * - title_display: Elements title_display setting.
+ * - required: An indicator for whether the associated form element is required.
+ * - attributes: A list of HTML attributes for the label.
+ *
+ * @see template_preprocess_form_element_label()
+ */
+#}
+{%
+ set classes = [
+ title_display == 'after' ? 'option',
+ title_display == 'invisible' ? 'visually-hidden',
+ required ? 'js-form-required',
+ required ? 'form-required',
+ type == 'textarea' ? 'txt-l'
+ ]
+%}
+{% if title is not empty or required -%}
+ {{ title }}
+{%- endif %}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element.html.twig
new file mode 100644
index 0000000000..99a97fdbac
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element.html.twig
@@ -0,0 +1,96 @@
+{#
+/**
+ * @file
+ * Theme override for a form element.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the containing element.
+ * - errors: (optional) Any errors for this form element, may not be set.
+ * - prefix: (optional) The form element prefix, may not be set.
+ * - suffix: (optional) The form element suffix, may not be set.
+ * - required: The required marker, or empty if the associated form element is
+ * not required.
+ * - type: The type of the element.
+ * - name: The name of the element.
+ * - label: A rendered label element.
+ * - label_display: Label display setting. It can have these values:
+ * - before: The label is output before the element. This is the default.
+ * The label includes the #title and the required marker, if #required.
+ * - after: The label is output after the element. For example, this is used
+ * for radio and checkbox #type elements. If the #title is empty but the
+ * field is #required, the label will contain only the required marker.
+ * - invisible: Labels are critical for screen readers to enable them to
+ * properly navigate through forms but can be visually distracting. This
+ * property hides the label for everyone except screen readers.
+ * - attribute: Set the title attribute on the element to create a tooltip but
+ * output no label element. This is supported only for checkboxes and radios
+ * in \Drupal\Core\Render\Element\CompositeFormElementTrait::preRenderCompositeFormElement().
+ * It is used where a visual label is not needed, such as a table of
+ * checkboxes where the row and column provide the context. The tooltip will
+ * include the title and required marker.
+ * - description: (optional) A list of description properties containing:
+ * - content: A description of the form element, may not be set.
+ * - attributes: (optional) A list of HTML attributes to apply to the
+ * description content wrapper. Will only be set when description is set.
+ * - description_display: Description display setting. It can have these values:
+ * - before: The description is output before the element.
+ * - after: The description is output after the element. This is the default
+ * value.
+ * - invisible: The description is output after the element, hidden visually
+ * but available to screen readers.
+ * - disabled: True if the element is disabled.
+ * - title_display: Title display setting.
+ *
+ * @see template_preprocess_form_element()
+ */
+#}
+{%
+ set classes = [
+ 'js-form-item',
+ 'form-item',
+ 'form-type-' ~ type|clean_class,
+ 'js-form-type-' ~ type|clean_class,
+ 'form-item-' ~ name|clean_class,
+ 'js-form-item-' ~ name|clean_class,
+ title_display not in ['after', 'before'] ? 'form-no-label',
+ disabled == 'disabled' ? 'form-disabled',
+ errors ? 'form-item--error',
+ type == 'textarea' ? 'txt'
+ ]
+%}
+{%
+ set description_classes = [
+ 'description',
+ description_display == 'invisible' ? 'visually-hidden',
+ ]
+%}
+
+ {% if label_display in ['before', 'invisible'] %}
+ {{ label }}
+ {% endif %}
+ {% if prefix is not empty %}
+
{{ prefix }}
+ {% endif %}
+ {% if description_display == 'before' and description.content %}
+
+ {{ description.content }}
+
+ {% endif %}
+ {{ children }}
+ {% if suffix is not empty %}
+
{{ suffix }}
+ {% endif %}
+ {% if label_display == 'after' %}
+ {{ label }}
+ {% endif %}
+ {% if errors %}
+
+ {{ errors }}
+
+ {% endif %}
+ {% if description_display in ['after', 'invisible'] and description.content %}
+
+ {{ description.content }}
+
+ {% endif %}
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form.html.twig
new file mode 100644
index 0000000000..655b5ccf75
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form.html.twig
@@ -0,0 +1,75 @@
+{#
+/**
+ * @file
+ * Theme override for a 'form' element.
+ *
+ * Available variables
+ * - attributes: A list of HTML attributes for the wrapper element.
+ * - configuration: The configuration for the block, including preset
+ * - preset: The preset used by this form instance
+ * - children: The child elements of the form.
+ *
+ * @see template_preprocess_form()
+ * @see bos_search_preprocess
+ */
+#}
+{% if form_header %}
+ {{ form_header }}
+{% endif %}
+
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/input.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/input.html.twig
new file mode 100644
index 0000000000..2722f0be54
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/input.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Theme override for an 'input' #type form element.
+ *
+ * Available variables:
+ * - attributes: A list of HTML attributes for the input element.
+ * - children: Optional additional rendered elements.
+ *
+ * @see template_preprocess_input()
+ */
+#}
+
+ {{ children }}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/js/preset.js b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/js/preset.js
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/radios.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/radios.html.twig
new file mode 100644
index 0000000000..6e9a9d795b
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/radios.html.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Theme override for a 'radios' #type form element.
+ *
+ * Available variables
+ * - attributes: A list of HTML attributes for the wrapper element.
+ * - children: The rendered radios.
+ *
+ * @see template_preprocess_radios()
+ */
+#}
+{{ children }}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/results.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/results.html.twig
new file mode 100644
index 0000000000..fc03ce51b6
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/results.html.twig
@@ -0,0 +1,143 @@
+{#
+/**
+ * @file
+ * Template for the Search Results Output.
+ *
+ * Available variables:
+ * - content: The question prompting this response.
+ * - response: The AI generated response as text.
+ * - items: An array of serach results, each element has
+ * - "content" => Extract from page defined by "link"
+ * - "id" => A unique id for the result
+ * - "link" => A link to the page result
+ * - "link_title" => A title for the page result, usually to the canonical homepage
+ * - "ref" => The reference to the result from the AI Model
+ * - "summary" => An annotated summary of the page result
+ * - "body" => A plain text summary of the page result
+ * - "title" => A title for the page result
+ * - metadata: An array with metadata returned by the AI Model.
+ * - references: An array with references returned by the AI Model.
+ * - citations: An array with citations returned by the AI Model.
+ * - id: The conversation ID for persistence.
+ */
+#}
+
+{% set ran = random(0,10000000) %}
+
+ {% if response %}
+
+
SUMMARY
+
{{ response|raw }}
+{#
#}
+
+
+
+ {% if citations %}
+
+
SOURCE LINKS
+
+
+
+
+ View source links
+
+
+
+
+
+
+
+ {% endif %}
+
+
+ {% if feedback %}
+ {{ feedback }}
+ {% endif %}
+
+ {% endif %}
+
+
+
+ {% if items %}
+
+
ADDITIONAL RESOURCES
+ {% for key,item in items %}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if metadata %}
+
+
+
+ {% endif %}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/select.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/select.html.twig
new file mode 100644
index 0000000000..9c8a97c058
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/select.html.twig
@@ -0,0 +1,27 @@
+{#
+/**
+ * @file
+ * Theme override for a select element.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the tag.
+ * - options: The element children.
+ *
+ * @see template_preprocess_select()
+ */
+#}
+{% apply spaceless %}
+
+ {% for option in options %}
+ {% if option.type == 'optgroup' %}
+
+ {% for sub_option in option.options %}
+ {{ sub_option.label }}
+ {% endfor %}
+
+ {% elseif option.type == 'option' %}
+ {{ option.label }}
+ {% endif %}
+ {% endfor %}
+
+{% endapply %}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/textarea.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/textarea.html.twig
new file mode 100644
index 0000000000..89c9af1ae1
--- /dev/null
+++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/textarea.html.twig
@@ -0,0 +1,25 @@
+{#
+/**
+ * @file
+ * Theme override for a 'textarea' #type form element.
+ *
+ * Available variables
+ * - wrapper_attributes: A list of HTML attributes for the wrapper element.
+ * - attributes: A list of HTML attributes for the