diff --git a/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs b/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs
new file mode 100644
index 00000000..2c6e2020
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/after-search-result-entry/search-result-decoration.gjs
@@ -0,0 +1,10 @@
+import Component from '@glimmer/component';
+import icon from "discourse-common/helpers/d-icon";
+
+export default class SearchResultDecoration extends Component {
+
+
+ {{icon "discourse-sparkles"}}
+
+
+}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs
new file mode 100644
index 00000000..80deea56
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.gjs
@@ -0,0 +1,148 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import I18n from "I18n";
+import { tracked } from "@glimmer/tracking";
+import { ajax } from "discourse/lib/ajax";
+import { isValidSearchTerm, translateResults } from "discourse/lib/search";
+import discourseDebounce from "discourse-common/lib/debounce";
+import { inject as service } from "@ember/service";
+import { bind } from "discourse-common/utils/decorators";
+import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
+import DToggleSwitch from "discourse/components/d-toggle-switch";
+import { on } from "@ember/modifier";
+import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
+import icon from "discourse-common/helpers/d-icon";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { withPluginApi } from "discourse/lib/plugin-api";
+
+export default class SemanticSearch extends Component {
+
+ {{#if this.searchEnabled}}
+
+
+
+
+
+
+ {{icon "discourse-sparkles"}}
+ {{this.searchStateText}}
+
+
+ {{#if this.searching}}
+
+ .
+ .
+ .
+
+ {{/if}}
+
+
+
+ {{/if}}
+
+
+ static shouldRender(_args, { siteSettings }) {
+ return siteSettings.ai_embeddings_semantic_search_enabled;
+ }
+
+ @service appEvents;
+ @service siteSettings;
+
+ @tracked searching = false;
+ @tracked AIResults = [];
+ @tracked showingAIResults = false;
+
+ get searchStateText() {
+ if (this.searching) {
+ return I18n.t("discourse_ai.embeddings.semantic_search_loading");
+ }
+
+ if (this.AIResults.length === 0) {
+ return I18n.t("discourse_ai.embeddings.semantic_search_results.none");
+ }
+
+ if (this.AIResults.length > 0) {
+ if (this.showingAIResults) {
+ return I18n.t(
+ "discourse_ai.embeddings.semantic_search_results.toggle",
+ {
+ count: this.AIResults.length,
+ }
+ );
+ } else {
+ return I18n.t(
+ "discourse_ai.embeddings.semantic_search_results.toggle_hidden",
+ {
+ count: this.AIResults.length,
+ }
+ );
+ }
+ }
+ }
+
+ get searchTerm() {
+ return this.args.outletArgs.search;
+ }
+
+ get searchEnabled() {
+ return (
+ this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
+ isValidSearchTerm(this.searchTerm, this.siteSettings)
+ );
+ }
+
+ @action
+ toggleAIResults() {
+ document.body.classList.toggle("showing-ai-results");
+ this.showingAIResults = !this.showingAIResults;
+ }
+
+ @action
+ resetAIResults() {
+ this.AIResults = [];
+ this.showingAIResults = false;
+ document.body.classList.remove("showing-ai-results");
+ }
+
+ @action
+ performHyDESearch() {
+ if (!this.searchEnabled) {
+ return;
+ }
+
+ withPluginApi("1.15.0", (api) => {
+ api.onAppEvent("full-page-search:trigger-search", () => {
+ this.searching = true;
+ this.resetAIResults();
+
+ ajax("/discourse-ai/embeddings/semantic-search", {
+ data: { q: this.searchTerm },
+ })
+ .then(async (results) => {
+ const model = (await translateResults(results)) || {};
+ const AIResults = model.posts.map(function (post) {
+ return Object.assign({}, post, { generatedByAI: true });
+ });
+
+ this.args.outletArgs.addSearchResults(AIResults, "topic_id");
+ this.AIResults = AIResults;
+ })
+ .catch(popupAjaxError)
+ .finally(() => (this.searching = false));
+ });
+ });
+ }
+}
diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs
deleted file mode 100644
index 9b71af92..00000000
--- a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.hbs
+++ /dev/null
@@ -1,51 +0,0 @@
-{{#if this.searchEnabled}}
-
-
- {{#if this.searching}}
-
-
- {{i18n "discourse_ai.embeddings.semantic_search_loading"}}
-
-
- .
- .
- .
-
-
- {{else}}
- {{#if this.results.length}}
-
-
-
-
- {{#unless this.collapsedResults}}
-
-
-
- {{/unless}}
- {{else}}
-
- {{i18n "discourse_ai.embeddings.semantic_search_results.none"}}
-
- {{/if}}
- {{/if}}
-
-
-{{/if}}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js
deleted file mode 100644
index 5bc0e0c3..00000000
--- a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/semantic-search.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import Component from "@glimmer/component";
-import { tracked } from "@glimmer/tracking";
-import { action, computed } from "@ember/object";
-import { inject as service } from "@ember/service";
-import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
-import { ajax } from "discourse/lib/ajax";
-import { isValidSearchTerm, translateResults } from "discourse/lib/search";
-import discourseDebounce from "discourse-common/lib/debounce";
-import { bind } from "discourse-common/utils/decorators";
-import I18n from "I18n";
-
-export default class extends Component {
- static shouldRender(_args, { siteSettings }) {
- return siteSettings.ai_embeddings_semantic_search_enabled;
- }
-
- @service appEvents;
- @service siteSettings;
-
- @tracked searching = true;
- @tracked collapsedResults = true;
- @tracked results = [];
-
- @computed("args.outletArgs.search")
- get searchTerm() {
- return this.args.outletArgs.search;
- }
-
- @computed("args.outletArgs.type", "searchTerm")
- get searchEnabled() {
- return (
- this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
- isValidSearchTerm(this.searchTerm, this.siteSettings)
- );
- }
-
- @computed("results")
- get collapsedResultsTitle() {
- return I18n.t("discourse_ai.embeddings.semantic_search_results.toggle", {
- count: this.results.length,
- });
- }
-
- @action
- setup() {
- this.appEvents.on(
- "full-page-search:trigger-search",
- this,
- "debouncedSearch"
- );
- }
-
- @action
- teardown() {
- this.appEvents.off(
- "full-page-search:trigger-search",
- this,
- "debouncedSearch"
- );
- }
-
- @bind
- performHyDESearch() {
- if (!this.searchEnabled) {
- return;
- }
-
- this.searching = true;
- this.collapsedResults = true;
- this.results = [];
-
- ajax("/discourse-ai/embeddings/semantic-search", {
- data: { q: this.searchTerm },
- })
- .then(async (results) => {
- const model = (await translateResults(results)) || {};
- this.results = model.posts;
- })
- .finally(() => (this.searching = false));
- }
-
- @action
- debouncedSearch() {
- discourseDebounce(this, this.performHyDESearch, 500);
- }
-}
diff --git a/assets/javascripts/initializers/ai-semantic-search.js b/assets/javascripts/initializers/ai-semantic-search.js
new file mode 100644
index 00000000..070b80b6
--- /dev/null
+++ b/assets/javascripts/initializers/ai-semantic-search.js
@@ -0,0 +1,9 @@
+import { apiInitializer } from "discourse/lib/api";
+
+export default apiInitializer("1.15.0", (api) => {
+ api.modifyClass("component:search-result-entry", {
+ pluginId: "discourse-ai",
+
+ classNameBindings: ["bulkSelectEnabled", "post.generatedByAI:ai-result"],
+ });
+});
diff --git a/assets/stylesheets/modules/embeddings/common/semantic-search.scss b/assets/stylesheets/modules/embeddings/common/semantic-search.scss
index fcf09cb6..e5940c46 100644
--- a/assets/stylesheets/modules/embeddings/common/semantic-search.scss
+++ b/assets/stylesheets/modules/embeddings/common/semantic-search.scss
@@ -8,13 +8,26 @@
align-items: baseline;
.semantic-search {
+ &__searching {
+ display: flex;
+ align-items: center;
+
+ &.in-progress {
+ .semantic-search__searching-text {
+ color: var(--primary-medium);
+ }
+ }
+ }
+
&__searching-text {
display: inline-block;
margin-left: 3px;
}
+
&__indicator-wave {
flex: 0 0 auto;
display: inline-flex;
+ color: var(--primary-medium);
}
&__indicator-dot {
display: inline-block;
@@ -37,3 +50,33 @@
}
}
}
+
+.search-results {
+ .fps-result {
+ padding: 0.5rem;
+
+ .ai-result__icon {
+ display: none;
+ }
+ }
+
+ .ai-result {
+ display: none;
+ background: var(--tertiary-very-low);
+ border-radius: var(--d-border-radius);
+
+ .ai-result__icon {
+ display: inline;
+ margin-right: 0.5rem;
+ margin-left: auto;
+ font-size: var(--font-up-2);
+ color: var(--tertiary);
+ }
+ }
+}
+
+.showing-ai-results {
+ .ai-result {
+ display: flex;
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 33f40e99..bdbd1d4c 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -80,7 +80,8 @@ en:
semantic_search: "Topics (Semantic)"
semantic_search_loading: "Searching for more results using AI"
semantic_search_results:
- toggle: "Found %{count} results using AI"
+ toggle: "Showing %{count} results found using AI"
+ toggle_hidden: "Hiding %{count} results found using AI"
none: "Sorry, our AI search found no matching topics."
ai_bot:
diff --git a/spec/system/embeddings/semantic_search_spec.rb b/spec/system/embeddings/semantic_search_spec.rb
new file mode 100644
index 00000000..962acf41
--- /dev/null
+++ b/spec/system/embeddings/semantic_search_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require_relative "../../support/embeddings_generation_stubs"
+require_relative "../../support/openai_completions_inference_stubs"
+
+RSpec.describe "AI Composer helper", type: :system, js: true do
+ let(:search_page) { PageObjects::Pages::Search.new }
+ let(:query) { "apple_pie" }
+ let(:hypothetical_post) { "This is an hypothetical post generated from the keyword apple_pie" }
+
+ fab!(:user) { Fabricate(:admin) }
+ fab!(:topic) { Fabricate(:topic) }
+ fab!(:post) { Fabricate(:post, topic: topic, raw: "Apple pie is a delicious dessert to eat") }
+
+ before do
+ SiteSetting.ai_embeddings_discourse_service_api_endpoint = "http://test.com"
+ prompt = DiscourseAi::Embeddings::HydeGenerators::OpenAi.new.prompt(query)
+ OpenAiCompletionsInferenceStubs.stub_response(
+ prompt,
+ hypothetical_post,
+ req_opts: {
+ max_tokens: 400,
+ },
+ )
+
+ hyde_embedding = [0.049382, 0.9999]
+ EmbeddingsGenerationStubs.discourse_service(
+ SiteSetting.ai_embeddings_model,
+ hypothetical_post,
+ hyde_embedding,
+ )
+
+ SearchIndexer.enable
+ SearchIndexer.index(topic, force: true)
+ SiteSetting.ai_embeddings_semantic_search_enabled = true
+ sign_in(user)
+ end
+
+ after do
+ described_class.clear_cache_for(query)
+ SearchIndexer.disable
+ end
+
+ describe "when performing a search in the full page search page" do
+ skip "TODO: Implement test after doing LLM abrstraction" do
+ it "performs AI search in the background and hides results by default" do
+ visit("/search?expanded=true")
+ search_page.type_in_search("apple pie")
+ search_page.click_search_button
+ end
+ end
+ end
+end