diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb
index 3f2f0db1..52b61115 100644
--- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb
+++ b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb
@@ -39,6 +39,51 @@ module DiscourseAi
status: 502
end
+ def suggest_title
+ raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
+
+ llm_prompt =
+ DiscourseAi::AiHelper::LlmPrompt
+ .new
+ .available_prompts(name_filter: "generate_titles")
+ .first
+ prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
+ raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
+
+ RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
+
+ hijack do
+ render json:
+ DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
+ prompt,
+ params[:text],
+ ),
+ status: 200
+ end
+ rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
+ ::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
+ ::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
+ render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
+ status: 502
+ end
+
+ def suggest_category
+ raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
+
+ RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
+
+ render json: DiscourseAi::AiHelper::SemanticCategorizer.new(params[:text]).categories,
+ status: 200
+ end
+
+ def suggest_tags
+ raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
+
+ RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
+
+ render json: DiscourseAi::AiHelper::SemanticCategorizer.new(params[:text]).tags, status: 200
+ end
+
private
def ensure_can_request_suggestions
diff --git a/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs
new file mode 100644
index 00000000..edca3919
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs
@@ -0,0 +1,184 @@
+import Component from '@glimmer/component';
+import DButton from "discourse/components/d-button";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import { bind } from "discourse-common/utils/decorators";
+import { inject as service } from "@ember/service";
+import I18n from "I18n";
+
+export default class AISuggestionDropdown extends Component {
+
+
+ {{#if this.showMenu}}
+ {{! template-lint-disable modifier-name-case }}
+
+ {{/if}}
+
+
+ @service dialog;
+ @service site;
+ @service siteSettings;
+ @tracked loading = false;
+ @tracked showMenu = false;
+ @tracked generatedSuggestions = [];
+ @tracked suggestIcon = "discourse-sparkles";
+ @tracked showErrors = false;
+ @tracked error = "";
+ SUGGESTION_TYPES = {
+ title: "suggest_title",
+ category: "suggest_category",
+ tag: "suggest_tags",
+ };
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ document.removeEventListener("click", this.onClickOutside);
+ }
+
+ get composerInput() {
+ return document.querySelector(".d-editor-input")?.value || this.args.composer.reply;
+ }
+
+ get disableSuggestionButton() {
+ return this.loading;
+ }
+
+ closeMenu() {
+ this.suggestIcon = "discourse-sparkles";
+ this.showMenu = false;
+ this.showErrors = false;
+ this.errors = "";
+ }
+
+ @bind
+ onClickOutside(event) {
+ const menu = document.querySelector(".ai-title-suggestions-menu");
+
+ if (event.target === menu) {
+ return;
+ }
+
+ return this.closeMenu();
+ }
+
+ @action
+ handleClickOutside() {
+ document.addEventListener("click", this.onClickOutside);
+ }
+
+ @action
+ applySuggestion(suggestion) {
+ if (!this.args.mode) {
+ return;
+ }
+
+ const composer = this.args?.composer;
+ if (!composer) {
+ return;
+ }
+
+
+ if (this.args.mode === this.SUGGESTION_TYPES.title) {
+ composer.set("title", suggestion);
+ return this.closeMenu();
+ }
+
+ if (this.args.mode === this.SUGGESTION_TYPES.category) {
+ const selectedCategoryId = this.site.categories.find((c) => c.slug === suggestion).id;
+ composer.set("categoryId", selectedCategoryId);
+ return this.closeMenu();
+ }
+
+ if (this.args.mode === this.SUGGESTION_TYPES.tag) {
+ this.updateTags(suggestion, composer);
+ }
+ }
+
+ updateTags(suggestion, composer) {
+ const maxTags = this.siteSettings.max_tags_per_topic;
+
+ if (!composer.tags) {
+ composer.set("tags", [suggestion]);
+ // remove tag from the list of suggestions once added
+ this.generatedSuggestions = this.generatedSuggestions.filter((s) => s !== suggestion);
+ return;
+ }
+ const tags = composer.tags;
+
+ if (tags?.length >= maxTags) {
+ // Show error if trying to add more tags than allowed
+ this.showErrors = true;
+ this.error = I18n.t("select_kit.max_content_reached", { count: maxTags});
+ return;
+ }
+
+ tags.push(suggestion);
+ composer.set("tags", [...tags]);
+ // remove tag from the list of suggestions once added
+ return this.generatedSuggestions = this.generatedSuggestions.filter((s) => s !== suggestion);
+ }
+
+ @action
+ async performSuggestion() {
+ if (!this.args.mode) {
+ return;
+ }
+
+ if (this.composerInput?.length === 0) {
+ return this.dialog.alert(I18n.t("discourse_ai.ai_helper.missing_content"));
+ }
+
+ this.loading = true;
+ this.suggestIcon = "spinner";
+
+ return ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
+ method: "POST",
+ data: { text: this.composerInput },
+ }).then((data) => {
+ if (this.args.mode === this.SUGGESTION_TYPES.title) {
+ this.generatedSuggestions = data.suggestions;
+ } else {
+ const suggestions = data.assistant.map((s) => s.name);
+ if (this.SUGGESTION_TYPES.tag) {
+ if (this.args.composer?.tags && this.args.composer?.tags.length > 0) {
+ // Filter out tags if they are already selected in the tag input
+ this.generatedSuggestions = suggestions.filter((t) => !this.args.composer.tags.includes(t));
+ } else {
+ this.generatedSuggestions = suggestions;
+ }
+ } else {
+ this.generatedSuggestions = suggestions;
+ }
+ }
+ }).catch(popupAjaxError).finally(() => {
+ this.loading = false;
+ this.suggestIcon = "sync-alt";
+ this.showMenu = true;
+ });
+
+ }
+}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs
new file mode 100644
index 00000000..c7fed1d7
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs
@@ -0,0 +1,13 @@
+import Component from '@glimmer/component';
+import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
+import { inject as service } from "@ember/service";
+
+export default class AICategorySuggestion extends Component {
+
+ {{#if this.siteSettings.ai_embeddings_enabled}}
+
+ {{/if}}
+
+
+ @service siteSettings;
+}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs
new file mode 100644
index 00000000..a90d0978
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs
@@ -0,0 +1,14 @@
+import Component from '@glimmer/component';
+import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
+import { inject as service } from "@ember/service";
+
+
+export default class AITagSuggestion extends Component {
+
+ {{#if this.siteSettings.ai_embeddings_enabled}}
+
+ {{/if}}
+
+
+ @service siteSettings;
+}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs
deleted file mode 100644
index 5817bcc6..00000000
--- a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggester.gjs
+++ /dev/null
@@ -1,116 +0,0 @@
-import Component from '@glimmer/component';
-import DButton from "discourse/components/d-button";
-import { tracked } from "@glimmer/tracking";
-import { action } from "@ember/object";
-import { ajax } from "discourse/lib/ajax";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import didInsert from "@ember/render-modifiers/modifiers/did-insert";
-import { bind } from "discourse-common/utils/decorators";
-import { inject as service } from "@ember/service";
-import I18n from "I18n";
-
-
-export default class AITitleSuggester extends Component {
-
-
- {{#if this.showMenu}}
- {{! template-lint-disable modifier-name-case }}
-
- {{/if}}
-
-
- @service dialog;
- @tracked loading = false;
- @tracked showMenu = false;
- @tracked generatedTitleSuggestions = [];
- @tracked suggestTitleIcon = "discourse-sparkles";
- mode = {
- id: -2,
- name: "generate_titles",
- translated_name: "Suggest topic titles",
- prompt_type: "list"
- };
-
- willDestroy() {
- super.willDestroy(...arguments);
- document.removeEventListener("click", this.onClickOutside);
- }
-
- get composerInput() {
- return document.querySelector(".d-editor-input")?.value || this.args.outletArgs.composer.reply;
- }
-
- get disableSuggestionButton() {
- return this.loading;
- }
-
- closeMenu() {
- this.suggestTitleIcon = "discourse-sparkles";
- this.showMenu = false;
- }
-
- @bind
- onClickOutside(event) {
- const menu = document.querySelector(".ai-title-suggestions-menu");
-
- if (event.target === menu) {
- return;
- }
-
- return this.closeMenu();
- }
-
- @action
- handleClickOutside() {
- document.addEventListener("click", this.onClickOutside);
- }
-
- @action
- updateTopicTitle(title) {
- const composer = this.args.outletArgs?.composer;
-
- if (composer) {
- composer.set("title", title);
- this.closeMenu();
- }
- }
-
- @action
- async suggestTitles() {
- if (this.composerInput?.length === 0) {
- return this.dialog.alert(I18n.t("discourse_ai.ai_helper.missing_content"));
- }
-
- this.loading = true;
- this.suggestTitleIcon = "spinner";
-
- return ajax("/discourse-ai/ai-helper/suggest", {
- method: "POST",
- data: { mode: this.mode.id, text: this.composerInput },
- }).then((data) => {
- this.generatedTitleSuggestions = data.suggestions;
- }).catch(popupAjaxError).finally(() => {
- this.loading = false;
- this.suggestTitleIcon = "sync-alt";
- this.showMenu = true;
- });
-
- }
-}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs
new file mode 100644
index 00000000..be8fd945
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs
@@ -0,0 +1,8 @@
+import Component from '@glimmer/component';
+import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
+
+export default class AITitleSuggestion extends Component {
+
+
+
+}
\ No newline at end of file
diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss
index d4efa827..22fefd81 100644
--- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss
+++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss
@@ -218,13 +218,24 @@
right: 1px;
background: none;
border: none;
+}
+.suggestion-button {
.d-icon-spinner {
animation: spin 1s linear infinite;
}
}
-.ai-title-suggestions-menu {
+.suggest-tags-button,
+.suggest-category-button {
+ display: block;
+ align-self: baseline;
+ border: 1px solid var(--primary-medium);
+ border-left: none;
+ background: none;
+}
+
+.ai-suggestions-menu {
list-style: none;
margin-left: 0;
position: absolute;
@@ -233,6 +244,20 @@
max-width: 25rem;
width: unset;
z-index: 999;
+
+ &__errors {
+ background: var(--danger);
+ padding: 0.25rem 1em;
+ color: var(--secondary);
+ }
+}
+
+.category-input:has(.ai-suggestions-menu) {
+ position: relative;
+}
+
+.suggest-tags-button + .ai-suggestions-menu {
+ top: 4.25rem;
}
@keyframes spin {
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 6077c311..f026b06a 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -16,7 +16,7 @@ en:
title: "Suggest changes using AI"
description: "Choose one of the options below, and the AI will suggest you a new version of the text."
selection_hint: "Hint: You can also select a portion of the text before opening the helper to rewrite only that."
- suggest_titles: "Suggest titles with AI"
+ suggest: "Suggest with AI"
missing_content: "Please enter some content to generate suggestions."
context_menu:
trigger: "AI"
diff --git a/config/routes.rb b/config/routes.rb
index 41a2a294..51b7d506 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,6 +4,9 @@ DiscourseAi::Engine.routes.draw do
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
get "prompts" => "assistant#prompts"
post "suggest" => "assistant#suggest"
+ post "suggest_title" => "assistant#suggest_title"
+ post "suggest_category" => "assistant#suggest_category"
+ post "suggest_tags" => "assistant#suggest_tags"
end
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
diff --git a/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb b/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb
index aa40a18d..c33157c8 100644
--- a/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb
+++ b/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb
@@ -2,7 +2,7 @@
CompletionPrompt.seed do |cp|
cp.id = -101
cp.provider = "anthropic"
- cp.name = "Traslate to English"
+ cp.name = "translate"
cp.prompt_type = CompletionPrompt.prompt_types[:text]
cp.messages = [{ role: "Human", content: <<~TEXT }]
I want you to act as an English translator, spelling corrector and improver. I will speak to you
@@ -17,7 +17,7 @@ end
CompletionPrompt.seed do |cp|
cp.id = -102
cp.provider = "anthropic"
- cp.name = "Suggest topic titles"
+ cp.name = "generate_titles"
cp.prompt_type = CompletionPrompt.prompt_types[:list]
cp.messages = [{ role: "Human", content: <<~TEXT }]
I want you to act as a title generator for written pieces. I will provide you with a text inside tags,
@@ -30,7 +30,7 @@ end
CompletionPrompt.seed do |cp|
cp.id = -103
cp.provider = "anthropic"
- cp.name = "Proofread"
+ cp.name = "proofread"
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
cp.messages = [{ role: "Human", content: <<~TEXT }]
You are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice.
@@ -46,7 +46,7 @@ end
CompletionPrompt.seed do |cp|
cp.id = -104
cp.provider = "anthropic"
- cp.name = "Convert to table"
+ cp.name = "markdown_table"
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
cp.messages = [{ role: "Human", content: <<~TEXT }]
You are a markdown table formatter, I will provide you text and you will format it into a markdown table.
diff --git a/lib/modules/ai_helper/entry_point.rb b/lib/modules/ai_helper/entry_point.rb
index cdafab20..14dbf29c 100644
--- a/lib/modules/ai_helper/entry_point.rb
+++ b/lib/modules/ai_helper/entry_point.rb
@@ -4,6 +4,7 @@ module DiscourseAi
class EntryPoint
def load_files
require_relative "llm_prompt"
+ require_relative "semantic_categorizer"
end
def inject_into(plugin)
diff --git a/lib/modules/ai_helper/llm_prompt.rb b/lib/modules/ai_helper/llm_prompt.rb
index 4eeab5eb..145e9007 100644
--- a/lib/modules/ai_helper/llm_prompt.rb
+++ b/lib/modules/ai_helper/llm_prompt.rb
@@ -3,8 +3,10 @@
module DiscourseAi
module AiHelper
class LlmPrompt
- def available_prompts
- CompletionPrompt
+ def available_prompts(name_filter: nil)
+ cp = CompletionPrompt
+ cp = cp.where(name: name_filter) if name_filter.present?
+ cp
.where(provider: enabled_provider)
.where(enabled: true)
.map do |prompt|
diff --git a/lib/modules/ai_helper/semantic_categorizer.rb b/lib/modules/ai_helper/semantic_categorizer.rb
new file mode 100644
index 00000000..34528ab9
--- /dev/null
+++ b/lib/modules/ai_helper/semantic_categorizer.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+module DiscourseAi
+ module AiHelper
+ class SemanticCategorizer
+ def initialize(text)
+ @text = text
+ end
+
+ def categories
+ return [] if @text.blank?
+ return [] unless SiteSetting.ai_embeddings_enabled
+
+ candidates =
+ ::DiscourseAi::Embeddings::SemanticSearch.new(nil).asymmetric_semantic_search(
+ @text,
+ 100,
+ 0,
+ return_distance: true,
+ )
+ candidate_ids = candidates.map(&:first)
+
+ ::Topic
+ .joins(:category)
+ .where(id: candidate_ids)
+ .order("array_position(ARRAY#{candidate_ids}, topics.id)")
+ .pluck("categories.slug")
+ .map
+ .with_index { |category, index| { name: category, score: candidates[index].last } }
+ .map do |c|
+ c[:score] = 1 / (c[:score] + 1) # inverse of the distance
+ c
+ end
+ .group_by { |c| c[:name] }
+ .map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
+ .sort_by { |c| -c[:score] }
+ .take(5)
+ end
+
+ def tags
+ return [] if @text.blank?
+ return [] unless SiteSetting.ai_embeddings_enabled
+
+ candidates =
+ ::DiscourseAi::Embeddings::SemanticSearch.new(nil).asymmetric_semantic_search(
+ @text,
+ 100,
+ 0,
+ return_distance: true,
+ )
+ candidate_ids = candidates.map(&:first)
+
+ ::Topic
+ .joins(:topic_tags, :tags)
+ .where(id: candidate_ids)
+ .group("topics.id")
+ .order("array_position(ARRAY#{candidate_ids}, topics.id)")
+ .pluck("array_agg(tags.name)")
+ .map(&:uniq)
+ .map
+ .with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } }
+ .flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } }
+ .map do |c|
+ c[:score] = 1 / (c[:score] + 1) # inverse of the distance
+ c
+ end
+ .group_by { |c| c[:name] }
+ .map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
+ .sort_by { |c| -c[:score] }
+ .take(5)
+ end
+ end
+ end
+end
diff --git a/lib/modules/embeddings/semantic_search.rb b/lib/modules/embeddings/semantic_search.rb
index 91b3fba1..e35fdc56 100644
--- a/lib/modules/embeddings/semantic_search.rb
+++ b/lib/modules/embeddings/semantic_search.rb
@@ -23,15 +23,15 @@ module DiscourseAi
.order("array_position(ARRAY#{candidate_ids}, topic_id)")
end
- def asymmetric_semantic_search(query, limit, offset)
+ def asymmetric_semantic_search(query, limit, offset, return_distance: false)
embedding = model.generate_embeddings(query)
table = @manager.topic_embeddings_table
begin
- candidate_ids =
- DB.query(<<~SQL, query_embedding: embedding, limit: limit, offset: offset).map(
+ candidate_ids = DB.query(<<~SQL, query_embedding: embedding, limit: limit, offset: offset)
SELECT
- topic_id
+ topic_id,
+ embeddings #{@model.pg_function} '[:query_embedding]' AS distance
FROM
#{table}
ORDER BY
@@ -39,8 +39,6 @@ module DiscourseAi
LIMIT :limit
OFFSET :offset
SQL
- &:topic_id
- )
rescue PG::Error => e
Rails.logger.error(
"Error #{e} querying embeddings for model #{model.name} and search #{query}",
@@ -48,7 +46,11 @@ module DiscourseAi
raise MissingEmbeddingError
end
- candidate_ids
+ if return_distance
+ candidate_ids.map { |c| [c.topic_id, c.distance] }
+ else
+ candidate_ids.map(&:topic_id)
+ end
end
private
diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb
index 8e40ba52..01674890 100644
--- a/spec/system/ai_helper/ai_composer_helper_spec.rb
+++ b/spec/system/ai_helper/ai_composer_helper_spec.rb
@@ -15,7 +15,13 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new }
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
- let(:ai_title_suggester) { PageObjects::Components::AITitleSuggester.new }
+ let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
+ fab!(:category) { Fabricate(:category) }
+ fab!(:video) { Fabricate(:tag) }
+ fab!(:music) { Fabricate(:tag) }
+ fab!(:cloud) { Fabricate(:tag) }
+ fab!(:feedback) { Fabricate(:tag) }
+ fab!(:review) { Fabricate(:tag) }
context "when using the translation mode" do
let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE }
@@ -296,22 +302,22 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
visit("/latest")
page.find("#create-topic").click
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
- ai_title_suggester.click_suggest_titles_button
+ ai_suggestion_dropdown.click_suggest_titles_button
- wait_for { ai_title_suggester.has_dropdown? }
+ wait_for { ai_suggestion_dropdown.has_dropdown? }
- expect(ai_title_suggester).to have_dropdown
+ expect(ai_suggestion_dropdown).to have_dropdown
end
it "replaces the topic title with the selected title" do
visit("/latest")
page.find("#create-topic").click
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
- ai_title_suggester.click_suggest_titles_button
+ ai_suggestion_dropdown.click_suggest_titles_button
- wait_for { ai_title_suggester.has_dropdown? }
+ wait_for { ai_suggestion_dropdown.has_dropdown? }
- ai_title_suggester.select_title_suggestion(2)
+ ai_suggestion_dropdown.select_suggestion(2)
expected_title = "The Quiet Piece that Moves Literature: A Gaucho's Story"
expect(find("#reply-title").value).to eq(expected_title)
@@ -321,13 +327,66 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
visit("/latest")
page.find("#create-topic").click
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
- ai_title_suggester.click_suggest_titles_button
+ ai_suggestion_dropdown.click_suggest_titles_button
- wait_for { ai_title_suggester.has_dropdown? }
+ wait_for { ai_suggestion_dropdown.has_dropdown? }
find(".d-editor-preview").click
- expect(ai_title_suggester).to have_no_dropdown
+ expect(ai_suggestion_dropdown).to have_no_dropdown
+ end
+ end
+
+ context "when suggesting the category with AI category suggester" do
+ before { SiteSetting.ai_embeddings_enabled = true }
+
+ it "updates the category with the suggested category" do
+ response =
+ Category
+ .take(3)
+ .pluck(:slug)
+ .map { |s| { name: s, score: rand(0.0...45.0) } }
+ .sort { |h| h[:score] }
+ DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:categories).returns(response)
+ visit("/latest")
+ page.find("#create-topic").click
+ composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
+ ai_suggestion_dropdown.click_suggest_category_button
+ wait_for { ai_suggestion_dropdown.has_dropdown? }
+
+ suggestion = ai_suggestion_dropdown.suggestion_name(0)
+ ai_suggestion_dropdown.select_suggestion(0)
+ category_selector = page.find(".category-chooser summary")
+
+ expect(category_selector["data-name"].downcase.gsub(" ", "-")).to eq(suggestion)
+ end
+ end
+
+ context "when suggesting the tags with AI tag suggester" do
+ before { SiteSetting.ai_embeddings_enabled = true }
+
+ it "updates the tag with the suggested tag" do
+ response =
+ Tag
+ .take(5)
+ .pluck(:name)
+ .map { |s| { name: s, score: rand(0.0...45.0) } }
+ .sort { |h| h[:score] }
+ DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:tags).returns(response)
+
+ visit("/latest")
+ page.find("#create-topic").click
+ composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
+
+ ai_suggestion_dropdown.click_suggest_tags_button
+
+ wait_for { ai_suggestion_dropdown.has_dropdown? }
+
+ suggestion = ai_suggestion_dropdown.suggestion_name(0)
+ ai_suggestion_dropdown.select_suggestion(0)
+ tag_selector = page.find(".mini-tag-chooser summary")
+
+ expect(tag_selector["data-name"]).to eq(suggestion)
end
end
end
diff --git a/spec/system/page_objects/components/ai_suggestion_dropdown.rb b/spec/system/page_objects/components/ai_suggestion_dropdown.rb
new file mode 100644
index 00000000..ed64e69d
--- /dev/null
+++ b/spec/system/page_objects/components/ai_suggestion_dropdown.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class AISuggestionDropdown < PageObjects::Components::Base
+ TITLE_BUTTON_SELECTOR = ".suggestion-button.suggest-titles-button"
+ CATEGORY_BUTTON_SELECTOR = ".suggestion-button.suggest-category-button"
+ TAG_BUTTON_SELECTOR = ".suggestion-button.suggest-tags-button"
+ MENU_SELECTOR = ".ai-suggestions-menu"
+
+ def click_suggest_titles_button
+ find(TITLE_BUTTON_SELECTOR, visible: :all).click
+ end
+
+ def click_suggest_category_button
+ find(CATEGORY_BUTTON_SELECTOR, visible: :all).click
+ end
+
+ def click_suggest_tags_button
+ find(TAG_BUTTON_SELECTOR, visible: :all).click
+ end
+
+ def select_suggestion(index)
+ find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click
+ end
+
+ def suggestion_name(index)
+ suggestion = find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]")
+ suggestion["data-name"]
+ end
+
+ def has_dropdown?
+ has_css?(MENU_SELECTOR)
+ end
+
+ def has_no_dropdown?
+ has_no_css?(MENU_SELECTOR)
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/ai_title_suggester.rb b/spec/system/page_objects/components/ai_title_suggester.rb
deleted file mode 100644
index 2cb4650e..00000000
--- a/spec/system/page_objects/components/ai_title_suggester.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module PageObjects
- module Components
- class AITitleSuggester < PageObjects::Components::Base
- BUTTON_SELECTOR = ".suggest-titles-button"
- MENU_SELECTOR = ".ai-title-suggestions-menu"
-
- def click_suggest_titles_button
- find(BUTTON_SELECTOR, visible: :all).click
- end
-
- def select_title_suggestion(index)
- find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click
- end
-
- def has_dropdown?
- has_css?(MENU_SELECTOR)
- end
-
- def has_no_dropdown?
- has_no_css?(MENU_SELECTOR)
- end
- end
- end
-end