diff --git a/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs b/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs new file mode 100644 index 00000000..edb2fb69 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs @@ -0,0 +1,147 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import categoryBadge from "discourse/helpers/category-badge"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import DMenu from "float-kit/components/d-menu"; +import eq from "truth-helpers/helpers/eq"; + +export default class AiSplitTopicSuggester extends Component { + @service site; + @service menu; + @tracked suggestions = []; + @tracked loading = false; + @tracked icon = "discourse-sparkles"; + SUGGESTION_TYPES = { + title: "suggest_title", + category: "suggest_category", + tag: "suggest_tags", + }; + + get input() { + return this.args.selectedPosts.map((item) => item.cooked).join("\n"); + } + + get disabled() { + return this.loading || this.suggestions.length > 0; + } + + @action + loadSuggestions() { + if (this.loading || this.suggestions.length > 0) { + return; + } + + this.loading = true; + + ajax(`/discourse-ai/ai-helper/${this.args.mode}`, { + method: "POST", + data: { text: this.input }, + }) + .then((result) => { + if (this.args.mode === this.SUGGESTION_TYPES.title) { + this.suggestions = result.suggestions; + } else if (this.args.mode === this.SUGGESTION_TYPES.category) { + const suggestions = result.assistant.map((s) => s.name); + const suggestedCategories = this.site.categories.filter((item) => + suggestions.includes(item.name.toLowerCase()) + ); + this.suggestions = suggestedCategories; + } else { + this.suggestions = result.assistant.map((s) => s.name); + } + }) + .catch(popupAjaxError) + .finally(() => { + this.loading = false; + }); + } + + @action + applySuggestion(suggestion, menu) { + if (!this.args.mode) { + return; + } + + if (this.args.mode === this.SUGGESTION_TYPES.title) { + this.args.updateAction(suggestion); + return menu.close(); + } + + if (this.args.mode === this.SUGGESTION_TYPES.category) { + this.args.updateAction(suggestion.id); + return menu.close(); + } + + if (this.args.mode === this.SUGGESTION_TYPES.tag) { + if (this.args.currentValue) { + if (Array.isArray(this.args.currentValue)) { + const updatedTags = [...this.args.currentValue, suggestion]; + this.args.updateAction([...new Set(updatedTags)]); + } else { + const updatedTags = [this.args.currentValue, suggestion]; + this.args.updateAction([...new Set(updatedTags)]); + } + } else { + this.args.updateAction(suggestion); + } + return menu.close(); + } + } + + +} diff --git a/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs new file mode 100644 index 00000000..b8ad9d94 --- /dev/null +++ b/assets/javascripts/discourse/connectors/split-new-topic-category-after/ai-category-suggestion.gjs @@ -0,0 +1,22 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester"; +import { showPostAIHelper } from "../../lib/show-ai-helper"; + +export default class AiCategorySuggestion extends Component { + static shouldRender(outletArgs, helper) { + return showPostAIHelper(outletArgs, helper); + } + + @service siteSettings; + + +} diff --git a/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs new file mode 100644 index 00000000..66a3e58e --- /dev/null +++ b/assets/javascripts/discourse/connectors/split-new-topic-tag-after/ai-tag-suggestion.gjs @@ -0,0 +1,23 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester"; +import { showPostAIHelper } from "../../lib/show-ai-helper"; + +export default class AiTagSuggestion extends Component { + static shouldRender(outletArgs, helper) { + return showPostAIHelper(outletArgs, helper); + } + + @service siteSettings; + + +} diff --git a/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs new file mode 100644 index 00000000..c5049b53 --- /dev/null +++ b/assets/javascripts/discourse/connectors/split-new-topic-title-after/ai-title-suggestion.gjs @@ -0,0 +1,17 @@ +import Component from "@glimmer/component"; +import AiSplitTopicSuggester from "../../components/ai-split-topic-suggester"; +import { showPostAIHelper } from "../../lib/show-ai-helper"; + +export default class AiTitleSuggestion extends Component { + static shouldRender(outletArgs, helper) { + return showPostAIHelper(outletArgs, helper); + } + + +} diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index a6dd42a4..5a0e6f4e 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -350,3 +350,78 @@ } } } + +.choose-topic-modal .split-new-topic-form { + .control-group { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 0.25em; + margin-bottom: 1rem; + + label { + flex: 100%; + } + + input, + .combo-box, + .multi-select { + flex: 1; + margin-bottom: 0; + } + } + + .ai-split-topic-suggestion-button { + .d-icon-spinner { + animation: spin 1s linear infinite; + } + } +} + +.ai-split-topic-suggestion__results { + list-style: none; + margin-left: 0; + margin: 0; + + .btn { + display: block; + width: 100%; + text-align: left; + background: none; + + &:hover, + &:focus { + background: var(--d-hover); + color: var(--primary); + } + } + + li:not(:last-child) { + border-bottom: 1px solid var(--primary-low); + } + + .ai-split-topic-suggestion__category-result { + font-size: var(--font-0); + padding: 0.5em 1rem; + + &:hover, + &:focus { + background: var(--d-hover); + cursor: pointer; + } + } +} + +.fk-d-menu[data-identifier="ai-split-topic-suggestion-menu"] { + z-index: z("modal", "dropdown"); +} + +.ai-split-topic-loading-placeholder { + .d-icon-spinner { + animation: spin 1s linear infinite; + } + + + .ai-split-topic-suggestion-button { + display: none; + } +} diff --git a/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb b/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb new file mode 100644 index 00000000..14be5b80 --- /dev/null +++ b/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +RSpec.describe "AI Post helper", type: :system, js: true do + fab!(:user) { Fabricate(:admin) } + fab!(:non_member_group) { Fabricate(:group) } + fab!(:topic) { Fabricate(:topic) } + fab!(:category) { Fabricate(:category) } + fab!(:category_2) { Fabricate(:category) } + fab!(:post) do + Fabricate( + :post, + topic: topic, + raw: + "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", + ) + end + fab!(:post_2) do + Fabricate( + :post, + topic: topic, + raw: "I prefer to eat croissants. They are my personal favorite dessert!", + ) + end + fab!(:post_3) do + Fabricate( + :post, + topic: topic, + raw: "I disagree with both of you, I think cake is the best dessert.", + ) + end + let(:topic_page) { PageObjects::Pages::Topic.new } + let(:suggestion_menu) { PageObjects::Components::AiSplitTopicSuggester.new } + fab!(:video) { Fabricate(:tag) } + fab!(:music) { Fabricate(:tag) } + fab!(:cloud) { Fabricate(:tag) } + fab!(:feedback) { Fabricate(:tag) } + fab!(:review) { Fabricate(:tag) } + + before do + Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) + SiteSetting.composer_ai_helper_enabled = true + sign_in(user) + end + + def open_move_topic_modal + topic_page.visit_topic(topic) + find(".topic-timeline .toggle-admin-menu").click + find(".topic-admin-multi-select .btn").click + find("#post_2 .select-posts .select-below").click + find(".move-to-topic").click + end + + describe "moving posts to a new topic" do + context "when suggesting titles with AI title suggester" do + let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:titles) do + "Pie: A delicious dessertCake is the best!Croissants are delightfulSome great dessertsWhat is the best dessert?" + end + + it "opens a menu with title suggestions" do + open_move_topic_modal + DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do + suggestion_menu.click_suggest_titles_button + wait_for { suggestion_menu.has_dropdown? } + expect(suggestion_menu).to have_dropdown + end + end + + it "replaces the title input with the selected title" do + open_move_topic_modal + DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do + suggestion_menu.click_suggest_titles_button + wait_for { suggestion_menu.has_dropdown? } + suggestion_menu.select_suggestion_by_value(1) + expected_title = "Cake is the best!" + expect(find("#split-topic-name").value).to eq(expected_title) + end + end + end + + context "when suggesting categories with AI category suggester" do + before { SiteSetting.ai_embeddings_enabled = true } + + skip "TODO: Category suggester only loading one category in test" do + it "updates the category with the suggested category" do + response = + Category + .take(3) + .pluck(:name) + .map { |s| { name: s, score: rand(0.0...45.0) } } + .sort { |h| h[:score] } + DiscourseAi::AiHelper::SemanticCategorizer + .any_instance + .stubs(:categories) + .returns(response) + + open_move_topic_modal + suggestion_menu.click_suggest_category_button + wait_for { suggestion_menu.has_dropdown? } + suggestion = category.name + suggestion_menu.select_suggestion_by_name(suggestion) + category_selector = page.find(".category-chooser summary") + + expect(category_selector["data-name"]).to eq(suggestion) + end + end + end + + context "when suggesting tags with AI tag suggester" do + before { SiteSetting.ai_embeddings_enabled = true } + + it "updatse 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) + + open_move_topic_modal + suggestion_menu.click_suggest_tags_button + wait_for { suggestion_menu.has_dropdown? } + + suggestion = suggestion_menu.suggestion_name(0) + suggestion_menu.select_suggestion_by_value(0) + tag_selector = page.find(".tag-chooser summary") + + expect(tag_selector["data-name"]).to eq(suggestion) + end + end + end +end diff --git a/spec/system/page_objects/components/ai_split_topic_suggester.rb b/spec/system/page_objects/components/ai_split_topic_suggester.rb new file mode 100644 index 00000000..528ba74e --- /dev/null +++ b/spec/system/page_objects/components/ai_split_topic_suggester.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class AiSplitTopicSuggester < PageObjects::Components::Base + SUGGESTION_BUTTON_SELECTOR = ".ai-split-topic-suggestion-button" + TITLE_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}[data-suggestion-mode='suggest_title']" + CATEGORY_BUTTON_SELECTOR = + "#{SUGGESTION_BUTTON_SELECTOR}[data-suggestion-mode='suggest_category']" + TAG_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}[data-suggestion-mode='suggest_tags']" + MENU_SELECTOR = ".fk-d-menu[data-identifier='ai-split-topic-suggestion-menu']" + + def click_suggest_titles_button + page.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_by_value(index) + find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click + end + + def select_suggestion_by_name(name) + find("#{MENU_SELECTOR} li[data-name=\"#{name}\"]").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 + + def has_suggestion_button? + has_css?(SUGGESTION_BUTTON_SELECTOR) + end + + def has_no_suggestion_button? + has_no_css?(SUGGESTION_BUTTON_SELECTOR) + end + end + end +end