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();
+ }
+ }
+
+
+ {{#if this.loading}}
+ {{!
+ Dynamically changing @icon of DMenu
+ causes it to rerender after load and
+ close the menu once data is loaded.
+ This workaround mimics an icon change of
+ the button by adding an overlapping
+ disabled button while loading}}
+
+ {{/if}}
+
+
+ {{#unless this.loading}}
+ {{#each this.suggestions as |suggestion index|}}
+ {{#if (eq @mode "suggest_category")}}
+ -
+ {{categoryBadge suggestion}}
+
+ {{else}}
+ -
+
+
+ {{/if}}
+ {{/each}}
+ {{/unless}}
+
+
+
+}
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;
+
+
+ {{#if this.siteSettings.ai_embeddings_enabled}}
+
+ {{/if}}
+
+}
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;
+
+
+ {{#if this.siteSettings.ai_embeddings_enabled}}
+
+ {{/if}}
+
+}
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 dessert
- Cake is the best!
- Croissants are delightful
- Some great desserts
- What 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