diff --git a/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs b/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs
index df8a3843..2e12c595 100644
--- a/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs
+++ b/assets/javascripts/discourse/components/ai-split-topic-suggester.gjs
@@ -54,8 +54,13 @@ export default class AiSplitTopicSuggester extends Component {
suggestions.includes(item.name.toLowerCase())
);
this.suggestions = suggestedCategories;
- } else {
- this.suggestions = result.assistant.map((s) => s.name);
+ } else if (this.args.mode === this.SUGGESTION_TYPES.tag) {
+ this.suggestions = result.assistant.map((s) => {
+ return {
+ name: s.name,
+ count: s.count,
+ };
+ });
}
})
.catch(popupAjaxError)
@@ -132,6 +137,17 @@ export default class AiSplitTopicSuggester extends Component {
{{on "click" (fn this.applySuggestion suggestion menu)}}
>
{{categoryBadge suggestion}}
+ x
+ {{suggestion.totalTopicCount}}
+
+ {{else if (eq @mode "suggest_tags")}}
+
+
+ x{{suggestion.count}}
+
{{else}}
diff --git a/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs
deleted file mode 100644
index f938ef1e..00000000
--- a/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs
+++ /dev/null
@@ -1,249 +0,0 @@
-import Component from "@glimmer/component";
-import { tracked } from "@glimmer/tracking";
-import { fn } from "@ember/helper";
-import { action } from "@ember/object";
-import didInsert from "@ember/render-modifiers/modifiers/did-insert";
-import { service } from "@ember/service";
-import DButton from "discourse/components/d-button";
-import { ajax } from "discourse/lib/ajax";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import { bind } from "discourse-common/utils/decorators";
-import I18n from "I18n";
-
-export default class AISuggestionDropdown extends Component {
- @service dialog;
- @service siteSettings;
- @service composer;
-
- @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 showAIButton() {
- const minCharacterCount = 40;
- const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
- const composerFields = document.querySelector(".composer-fields");
-
- if (composerFields) {
- if (isShowAIButton) {
- composerFields.classList.add("showing-ai-suggestions");
- } else {
- composerFields.classList.remove("showing-ai-suggestions");
- }
- }
-
- return isShowAIButton;
- }
-
- get disableSuggestionButton() {
- return this.loading;
- }
-
- @action
- applyClasses() {
- if (this.showAIButton) {
- document
- .querySelector(".composer-fields")
- ?.classList.add("showing-ai-suggestions");
- } else {
- document
- .querySelector(".composer-fields")
- ?.classList.remove("showing-ai-suggestions");
- }
- }
-
- @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.composer.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);
- }
- }
-
- @action
- async performSuggestion() {
- if (!this.args.mode) {
- return;
- }
-
- if (this.composer.model.replyLength === 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.composer.model.reply },
- })
- .then((data) => {
- this.#assignGeneratedSuggestions(data, this.args.mode);
- })
- .catch(popupAjaxError)
- .finally(() => {
- this.loading = false;
- this.suggestIcon = "sync-alt";
- this.showMenu = true;
-
- if (this.args.mode === "suggest_category") {
- document
- .querySelector(".category-input")
- ?.classList.add("showing-ai-suggestion-menu");
- }
- });
- }
-
- #closeMenu() {
- if (this.showMenu && this.args.mode === "suggest_category") {
- document
- .querySelector(".category-input")
- ?.classList.remove("showing-ai-suggestion-menu");
- }
-
- this.suggestIcon = "discourse-sparkles";
- this.showMenu = false;
- this.showErrors = false;
- this.errors = "";
- }
-
- #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
- ));
- }
-
- #tagSelectorHasValues() {
- return this.args.composer?.tags && this.args.composer?.tags.length > 0;
- }
-
- #assignGeneratedSuggestions(data, mode) {
- if (mode === this.SUGGESTION_TYPES.title) {
- return (this.generatedSuggestions = data.suggestions);
- }
-
- const suggestions = data.assistant.map((s) => s.name);
-
- if (mode === this.SUGGESTION_TYPES.tag) {
- if (this.#tagSelectorHasValues()) {
- // Filter out tags if they are already selected in the tag input
- return (this.generatedSuggestions = suggestions.filter(
- (t) => !this.args.composer.tags.includes(t)
- ));
- } else {
- return (this.generatedSuggestions = suggestions);
- }
- }
-
- return (this.generatedSuggestions = suggestions);
- }
-
-
- {{#if this.showAIButton}}
-
- {{/if}}
-
- {{#if this.showMenu}}
- {{! template-lint-disable modifier-name-case }}
-
- {{/if}}
-
-}
diff --git a/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs b/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs
new file mode 100644
index 00000000..2728630b
--- /dev/null
+++ b/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs
@@ -0,0 +1,154 @@
+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 { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DropdownMenu from "discourse/components/dropdown-menu";
+import categoryBadge from "discourse/helpers/category-badge";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import i18n from "discourse-common/helpers/i18n";
+import DMenu from "float-kit/components/d-menu";
+import { MIN_CHARACTER_COUNT } from "../../lib/ai-helper-suggestions";
+
+export default class AiCategorySuggester extends Component {
+ @service siteSettings;
+ @tracked loading = false;
+ @tracked suggestions = null;
+ @tracked untriggers = [];
+ @tracked triggerIcon = "discourse-sparkles";
+ @tracked content = null;
+ @tracked topicContent = null;
+
+ constructor() {
+ super(...arguments);
+ if (!this.topicContent && this.args.composer?.reply === undefined) {
+ this.fetchTopicContent();
+ }
+ }
+
+ async fetchTopicContent() {
+ await ajax(`/t/${this.args.buffered.content.id}.json`).then(
+ ({ post_stream }) => {
+ this.topicContent = post_stream.posts[0].cooked;
+ }
+ );
+ }
+
+ get showSuggestionButton() {
+ const composerFields = document.querySelector(".composer-fields");
+ this.content = this.args.composer?.reply || this.topicContent;
+ const showTrigger = this.content?.length > MIN_CHARACTER_COUNT;
+
+ if (composerFields) {
+ if (showTrigger) {
+ composerFields.classList.add("showing-ai-suggestions");
+ } else {
+ composerFields.classList.remove("showing-ai-suggestions");
+ }
+ }
+
+ return this.siteSettings.ai_embeddings_enabled && showTrigger;
+ }
+
+ @action
+ async loadSuggestions() {
+ if (this.suggestions && !this.dMenu.expanded) {
+ return this.suggestions;
+ }
+
+ this.loading = true;
+ this.triggerIcon = "spinner";
+
+ try {
+ const { assistant } = await ajax(
+ "/discourse-ai/ai-helper/suggest_category",
+ {
+ method: "POST",
+ data: { text: this.content },
+ }
+ );
+ this.suggestions = assistant;
+ } catch (error) {
+ popupAjaxError(error);
+ } finally {
+ this.loading = false;
+ this.triggerIcon = "sync-alt";
+ }
+
+ return this.suggestions;
+ }
+
+ @action
+ applySuggestion(suggestion) {
+ const composer = this.args.composer;
+ const buffered = this.args.buffered;
+
+ if (composer) {
+ composer.set("categoryId", suggestion.id);
+ composer.get("categoryId");
+ }
+
+ if (buffered) {
+ this.args.buffered.set("category_id", suggestion.id);
+ }
+
+ return this.dMenu.close();
+ }
+
+ @action
+ onRegisterApi(api) {
+ this.dMenu = api;
+ }
+
+ @action
+ onClose() {
+ this.triggerIcon = "discourse-sparkles";
+ }
+
+
+ {{#if this.showSuggestionButton}}
+
+ <:content>
+ {{#unless this.loading}}
+
+ {{#each this.suggestions as |suggestion index|}}
+
+
+
+ {{categoryBadge suggestion}}
+ x
+ {{suggestion.topicCount}}
+
+
+
+ {{/each}}
+
+ {{/unless}}
+
+
+ {{/if}}
+
+}
diff --git a/assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs b/assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs
new file mode 100644
index 00000000..7cf021ad
--- /dev/null
+++ b/assets/javascripts/discourse/components/suggestion-menus/ai-tag-suggester.gjs
@@ -0,0 +1,218 @@
+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 { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DropdownMenu from "discourse/components/dropdown-menu";
+import discourseTag from "discourse/helpers/discourse-tag";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import i18n from "discourse-common/helpers/i18n";
+import DMenu from "float-kit/components/d-menu";
+import { MIN_CHARACTER_COUNT } from "../../lib/ai-helper-suggestions";
+
+export default class AiTagSuggester extends Component {
+ @service siteSettings;
+ @service toasts;
+ @tracked loading = false;
+ @tracked suggestions = null;
+ @tracked untriggers = [];
+ @tracked triggerIcon = "discourse-sparkles";
+ @tracked content = null;
+ @tracked topicContent = null;
+
+ constructor() {
+ super(...arguments);
+ if (!this.topicContent && this.args.composer?.reply === undefined) {
+ this.fetchTopicContent();
+ }
+ }
+
+ async fetchTopicContent() {
+ await ajax(`/t/${this.args.buffered.content.id}.json`).then(
+ ({ post_stream }) => {
+ this.topicContent = post_stream.posts[0].cooked;
+ }
+ );
+ }
+
+ get showSuggestionButton() {
+ const composerFields = document.querySelector(".composer-fields");
+ this.content = this.args.composer?.reply || this.topicContent;
+ const showTrigger = this.content?.length > MIN_CHARACTER_COUNT;
+
+ if (composerFields) {
+ if (showTrigger) {
+ composerFields.classList.add("showing-ai-suggestions");
+ } else {
+ composerFields.classList.remove("showing-ai-suggestions");
+ }
+ }
+
+ return this.siteSettings.ai_embeddings_enabled && showTrigger;
+ }
+
+ get showDropdown() {
+ if (this.suggestions?.length <= 0) {
+ this.dMenu.close();
+ }
+ return !this.loading && this.suggestions?.length > 0;
+ }
+
+ @action
+ async loadSuggestions() {
+ if (
+ this.suggestions &&
+ this.suggestions?.length > 0 &&
+ !this.dMenu.expanded
+ ) {
+ return this.suggestions;
+ }
+
+ this.loading = true;
+ this.triggerIcon = "spinner";
+
+ try {
+ const { assistant } = await ajax("/discourse-ai/ai-helper/suggest_tags", {
+ method: "POST",
+ data: { text: this.content },
+ });
+ this.suggestions = assistant;
+ const model = this.args.composer
+ ? this.args.composer
+ : this.args.buffered;
+ if (this.#tagSelectorHasValues()) {
+ this.suggestions = this.suggestions.filter(
+ (s) => !model.get("tags").includes(s.name)
+ );
+ }
+
+ if (this.suggestions?.length <= 0) {
+ this.toasts.error({
+ class: "ai-suggestion-error",
+ duration: 3000,
+ data: {
+ message: i18n(
+ "discourse_ai.ai_helper.suggest_errors.no_suggestions"
+ ),
+ },
+ });
+ return;
+ }
+ } catch (error) {
+ popupAjaxError(error);
+ } finally {
+ this.loading = false;
+ this.triggerIcon = "sync-alt";
+ }
+
+ return this.suggestions;
+ }
+
+ #tagSelectorHasValues() {
+ return this.args.composer?.tags && this.args.composer?.tags.length > 0;
+ }
+
+ #removedAppliedTag(suggestion) {
+ return (this.suggestions = this.suggestions.filter(
+ (s) => s.id !== suggestion.id
+ ));
+ }
+
+ @action
+ applySuggestion(suggestion) {
+ const maxTags = this.siteSettings.max_tags_per_topic;
+ const model = this.args.composer ? this.args.composer : this.args.buffered;
+ if (!model) {
+ return;
+ }
+
+ const tags = model.get("tags");
+
+ if (!tags) {
+ model.set("tags", [suggestion.name]);
+ this.#removedAppliedTag(suggestion);
+ return;
+ }
+
+ if (tags?.length >= maxTags) {
+ return this.toasts.error({
+ class: "ai-suggestion-error",
+ duration: 3000,
+ data: {
+ message: i18n("discourse_ai.ai_helper.suggest_errors.too_many_tags", {
+ count: maxTags,
+ }),
+ },
+ });
+ }
+
+ tags.push(suggestion.name);
+ model.set("tags", [...tags]);
+ suggestion.disabled = true;
+ this.#removedAppliedTag(suggestion);
+ }
+
+ @action
+ onRegisterApi(api) {
+ this.dMenu = api;
+ }
+
+ @action
+ onClose() {
+ if (this.suggestions?.length > 0) {
+ // If all suggestions have been used,
+ // re-triggering when no suggestions present
+ // will cause computation issues with
+ // setting the icon, so we prevent it
+ this.triggerIcon = "discourse-sparkles";
+ }
+ }
+
+
+ {{#if this.showSuggestionButton}}
+
+ <:content>
+ {{#if this.showDropdown}}
+
+ {{#each this.suggestions as |suggestion index|}}
+
+
+ {{discourseTag
+ suggestion.name
+ count=suggestion.count
+ noHref=true
+ }}
+
+
+ {{/each}}
+
+ {{/if}}
+
+
+ {{/if}}
+
+}
diff --git a/assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs b/assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs
new file mode 100644
index 00000000..286274c4
--- /dev/null
+++ b/assets/javascripts/discourse/components/suggestion-menus/ai-title-suggester.gjs
@@ -0,0 +1,151 @@
+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 DButton from "discourse/components/d-button";
+import DropdownMenu from "discourse/components/dropdown-menu";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import i18n from "discourse-common/helpers/i18n";
+import DMenu from "float-kit/components/d-menu";
+import { MIN_CHARACTER_COUNT } from "../../lib/ai-helper-suggestions";
+
+export default class AiTitleSuggester extends Component {
+ @tracked loading = false;
+ @tracked suggestions = null;
+ @tracked untriggers = [];
+ @tracked triggerIcon = "discourse-sparkles";
+ @tracked content = null;
+ @tracked topicContent = null;
+
+ constructor() {
+ super(...arguments);
+
+ if (!this.topicContent && this.args.composer?.reply === undefined) {
+ this.fetchTopicContent();
+ }
+ }
+
+ async fetchTopicContent() {
+ await ajax(`/t/${this.args.buffered.content.id}.json`).then(
+ ({ post_stream }) => {
+ this.topicContent = post_stream.posts[0].cooked;
+ }
+ );
+ }
+
+ get showSuggestionButton() {
+ const composerFields = document.querySelector(".composer-fields");
+ const editTopicTitleField = document.querySelector(".edit-topic-title");
+
+ this.content = this.args.composer?.reply || this.topicContent;
+ const showTrigger = this.content?.length > MIN_CHARACTER_COUNT;
+
+ if (composerFields) {
+ if (showTrigger) {
+ composerFields.classList.add("showing-ai-suggestions");
+ } else {
+ composerFields.classList.remove("showing-ai-suggestions");
+ }
+ }
+
+ if (editTopicTitleField) {
+ if (showTrigger) {
+ editTopicTitleField.classList.add("showing-ai-suggestions");
+ } else {
+ editTopicTitleField.classList.remove("showing-ai-suggestions");
+ }
+ }
+
+ return showTrigger;
+ }
+
+ @action
+ async loadSuggestions() {
+ if (this.suggestions && !this.dMenu.expanded) {
+ return this.suggestions;
+ }
+
+ this.loading = true;
+ this.triggerIcon = "spinner";
+
+ try {
+ const { suggestions } = await ajax(
+ "/discourse-ai/ai-helper/suggest_title",
+ {
+ method: "POST",
+ data: { text: this.content },
+ }
+ );
+ this.suggestions = suggestions;
+ } catch (error) {
+ popupAjaxError(error);
+ } finally {
+ this.loading = false;
+ this.triggerIcon = "sync-alt";
+ }
+
+ return this.suggestions;
+ }
+
+ @action
+ applySuggestion(suggestion) {
+ const model = this.args.composer ? this.args.composer : this.args.buffered;
+ if (!model) {
+ return;
+ }
+
+ model.set("title", suggestion);
+ this.dMenu.close();
+ }
+
+ @action
+ onRegisterApi(api) {
+ this.dMenu = api;
+ }
+
+ @action
+ onClose() {
+ this.triggerIcon = "discourse-sparkles";
+ }
+
+
+ {{#if this.showSuggestionButton}}
+
+ <:content>
+ {{#unless this.loading}}
+
+ {{#each this.suggestions as |suggestion index|}}
+
+
+ {{suggestion}}
+
+
+ {{/each}}
+
+ {{/unless}}
+
+
+ {{/if}}
+
+}
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
index a327359a..d7bef642 100644
--- 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
@@ -1,6 +1,5 @@
import Component from "@glimmer/component";
-import { service } from "@ember/service";
-import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
+import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiCategorySuggestion extends Component {
@@ -13,15 +12,7 @@ export default class AiCategorySuggestion extends Component {
);
}
- @service siteSettings;
-
- {{#if this.siteSettings.ai_embeddings_enabled}}
-
- {{/if}}
+
}
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
index ae18d06b..ac6ad686 100644
--- 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
@@ -1,6 +1,5 @@
import Component from "@glimmer/component";
-import { service } from "@ember/service";
-import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
+import AiTagSuggester from "../../components/suggestion-menus/ai-tag-suggester";
import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiTagSuggestion extends Component {
@@ -13,15 +12,7 @@ export default class AiTagSuggestion extends Component {
);
}
- @service siteSettings;
-
- {{#if this.siteSettings.ai_embeddings_enabled}}
-
- {{/if}}
+
}
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
index 3377c086..691f210d 100644
--- 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
@@ -1,5 +1,5 @@
import Component from "@glimmer/component";
-import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
+import AiTitleSuggester from "../../components/suggestion-menus/ai-title-suggester";
import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiTitleSuggestion extends Component {
@@ -13,10 +13,6 @@ export default class AiTitleSuggestion extends Component {
}
-
+
}
diff --git a/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs
new file mode 100644
index 00000000..c1f5c0c0
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs
@@ -0,0 +1,18 @@
+import Component from "@glimmer/component";
+import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
+import { showComposerAiHelper } from "../../lib/show-ai-helper";
+
+export default class AiCategorySuggestion extends Component {
+ static shouldRender(outletArgs, helper) {
+ return showComposerAiHelper(
+ outletArgs?.composer,
+ helper.siteSettings,
+ helper.currentUser,
+ "suggestions"
+ );
+ }
+
+
+
+
+}
diff --git a/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs
new file mode 100644
index 00000000..3ab8656f
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/edit-topic-tags__after/ai-tag-suggestion.gjs
@@ -0,0 +1,18 @@
+import Component from "@glimmer/component";
+import AiTagSuggester from "../../components/suggestion-menus/ai-tag-suggester";
+import { showComposerAiHelper } from "../../lib/show-ai-helper";
+
+export default class AiCategorySuggestion extends Component {
+ static shouldRender(outletArgs, helper) {
+ return showComposerAiHelper(
+ outletArgs?.composer,
+ helper.siteSettings,
+ helper.currentUser,
+ "suggestions"
+ );
+ }
+
+
+
+
+}
diff --git a/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs
new file mode 100644
index 00000000..d3449e8f
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/edit-topic-title__after/ai-title-suggestion.gjs
@@ -0,0 +1,18 @@
+import Component from "@glimmer/component";
+import AiTitleSuggester from "../../components/suggestion-menus/ai-title-suggester";
+import { showComposerAiHelper } from "../../lib/show-ai-helper";
+
+export default class AiTitleSuggestion extends Component {
+ static shouldRender(outletArgs, helper) {
+ return showComposerAiHelper(
+ outletArgs?.composer,
+ helper.siteSettings,
+ helper.currentUser,
+ "suggestions"
+ );
+ }
+
+
+
+
+}
diff --git a/assets/javascripts/discourse/lib/ai-helper-suggestions.js b/assets/javascripts/discourse/lib/ai-helper-suggestions.js
new file mode 100644
index 00000000..91ec2e94
--- /dev/null
+++ b/assets/javascripts/discourse/lib/ai-helper-suggestions.js
@@ -0,0 +1 @@
+export const MIN_CHARACTER_COUNT = 40;
diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss
index 8c8d1265..ff0acfc1 100644
--- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss
+++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss
@@ -228,25 +228,50 @@
border-bottom-left-radius: 0;
}
-.ai-suggestions-menu {
- list-style: none;
- margin-left: 0;
- position: absolute;
- right: 0;
- top: 1.5rem;
- max-width: 25rem;
- width: unset;
- z-index: 999;
+.ai-category-suggester-content,
+.ai-tag-suggester-content,
+.ai-title-suggester-content {
+ z-index: z("composer", "dropdown");
+}
- &__errors {
- background: var(--danger);
- padding: 0.25rem 1em;
- color: var(--secondary);
+.ai-category-suggester-content {
+ .category-row {
+ padding: 0.25em 0.5em;
+ color: var(--primary-high);
+
+ &:hover {
+ background: var(--d-hover);
+ }
+ }
+
+ .topic-count {
+ font-size: var(--font-down-2);
}
}
-.category-input.showing-ai-suggestion-menu {
- position: relative;
+.ai-tag-suggester-content {
+ .tag-row {
+ .discourse-tag-count {
+ margin-left: 5px;
+ }
+ }
+}
+
+.edit-topic-title {
+ .suggestion-button {
+ margin: 0;
+ padding: 0.45rem;
+ }
+}
+
+#topic-title .edit-topic-title.showing-ai-suggestions {
+ #edit-title {
+ flex: 1 1 90%;
+ }
+
+ .suggest-titles-button {
+ padding: 0.5rem;
+ }
}
// Prevent suggestion button from wrapping
@@ -393,6 +418,11 @@
cursor: pointer;
}
}
+
+ .topic-count {
+ font-size: var(--font-down-2);
+ color: var(--primary-high);
+ }
}
.fk-d-menu[data-identifier="ai-split-topic-suggestion-menu"] {
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 0c6ddab7..dd0d5c06 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -344,6 +344,11 @@ en:
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: "Suggest with AI"
+ suggest_errors:
+ too_many_tags:
+ one: "You can only have up to %{count} tag"
+ other: "You can only have up to %{count} tags"
+ no_suggestions: "No suggestions available"
missing_content: "Please enter some content to generate suggestions."
context_menu:
trigger: "Ask AI"
diff --git a/lib/ai_helper/semantic_categorizer.rb b/lib/ai_helper/semantic_categorizer.rb
index b759c6c3..7b6f953f 100644
--- a/lib/ai_helper/semantic_categorizer.rb
+++ b/lib/ai_helper/semantic_categorizer.rb
@@ -19,15 +19,30 @@ module DiscourseAi
.where(id: candidate_ids)
.where("categories.id IN (?)", Category.topic_create_allowed(@user.guardian).pluck(:id))
.order("array_position(ARRAY#{candidate_ids}, topics.id)")
- .pluck("categories.slug")
+ .pluck(
+ "categories.id",
+ "categories.name",
+ "categories.slug",
+ "categories.color",
+ "categories.topic_count",
+ )
.map
- .with_index { |category, index| { name: category, score: candidates[index].last } }
+ .with_index do |(id, name, slug, color, topic_count), index|
+ {
+ id: id,
+ name: name,
+ slug: slug,
+ color: color,
+ topicCount: topic_count,
+ score: candidates[index].last,
+ }
+ end
.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] } } }
+ .map { |name, scores| scores.first.merge(score: scores.sum { |s| s[:score] }) }
.sort_by { |c| -c[:score] }
.take(5)
end
@@ -39,24 +54,35 @@ module DiscourseAi
candidates = nearest_neighbors(limit: 100)
candidate_ids = candidates.map(&:first)
+ count_column = Tag.topic_count_column(@user.guardian) # Determine the count column
+
::Topic
.joins(:topic_tags, :tags)
.where(id: candidate_ids)
.where("tags.id IN (?)", DiscourseTagging.visible_tags(@user.guardian).pluck(:id))
- .group("topics.id")
+ .group("topics.id, tags.id, tags.name") # Group by topics.id and tags.id
.order("array_position(ARRAY#{candidate_ids}, topics.id)")
- .pluck("array_agg(tags.name)")
- .map(&:uniq)
+ .pluck(
+ "tags.id",
+ "tags.name",
+ "tags.#{count_column}",
+ "MIN(array_position(ARRAY#{candidate_ids}, topics.id))", # Get minimum index for ordering
+ )
+ .uniq # Ensure unique tags per topic
.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
+ .with_index do |(id, name, count, index), idx|
+ {
+ id: id,
+ name: name,
+ count: count,
+ score: 1 / (candidates[idx].last + 1), # Inverse of the distance for score
+ }
end
- .group_by { |c| c[:name] }
- .map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
- .sort_by { |c| -c[:score] }
+ .group_by { |tag| tag[:name] }
+ .map do |name, tags|
+ tags.first.merge(score: tags.sum { |t| t[:score] })
+ end # Aggregate scores per tag
+ .sort_by { |tag| -tag[:score] }
.take(5)
end
diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb
index 5f76386d..662fd9d2 100644
--- a/spec/system/ai_helper/ai_composer_helper_spec.rb
+++ b/spec/system/ai_helper/ai_composer_helper_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:composer) { PageObjects::Components::Composer.new }
let(:ai_helper_menu) { PageObjects::Components::AiComposerHelperMenu.new }
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
- let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
+ let(:ai_suggestion_dropdown) { PageObjects::Components::AiSuggestionDropdown.new }
let(:toasts) { PageObjects::Components::Toasts.new }
fab!(:category)
@@ -236,20 +236,26 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
response =
Category
.take(3)
- .pluck(:slug)
- .map { |s| { name: s, score: rand(0.0...45.0) } }
- .sort { |h| h[:score] }
+ .map do |category|
+ {
+ id: category.id,
+ name: category.name,
+ slug: category.slug,
+ color: category.color,
+ score: rand(0.0...45.0),
+ topicCount: rand(1..3),
+ }
+ end
+ .sort_by { |h| h[:score] }
DiscourseAi::AiHelper::SemanticCategorizer.any_instance.stubs(:categories).returns(response)
visit("/latest")
page.find("#create-topic").click
composer.fill_content(input)
ai_suggestion_dropdown.click_suggest_category_button
wait_for { ai_suggestion_dropdown.has_dropdown? }
-
suggestion = category_2.name
- ai_suggestion_dropdown.select_suggestion_by_name(category_2.slug)
+ ai_suggestion_dropdown.select_suggestion_by_name(suggestion)
category_selector = page.find(".category-chooser summary")
-
expect(category_selector["data-name"]).to eq(suggestion)
end
end
diff --git a/spec/system/page_objects/components/ai_suggestion_dropdown.rb b/spec/system/page_objects/components/ai_suggestion_dropdown.rb
index bb7a910c..5d061dad 100644
--- a/spec/system/page_objects/components/ai_suggestion_dropdown.rb
+++ b/spec/system/page_objects/components/ai_suggestion_dropdown.rb
@@ -2,7 +2,7 @@
module PageObjects
module Components
- class AISuggestionDropdown < PageObjects::Components::Base
+ class AiSuggestionDropdown < PageObjects::Components::Base
SUGGESTION_BUTTON_SELECTOR = ".suggestion-button"
TITLE_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}.suggest-titles-button"
CATEGORY_BUTTON_SELECTOR = "#{SUGGESTION_BUTTON_SELECTOR}.suggest-category-button"
@@ -22,15 +22,15 @@ module PageObjects
end
def select_suggestion_by_value(index)
- find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]").click
+ find("#{MENU_SELECTOR} button[data-value=\"#{index}\"]").click
end
def select_suggestion_by_name(name)
- find("#{MENU_SELECTOR} li[data-name=\"#{name}\"]").click
+ find("#{MENU_SELECTOR} button[data-name=\"#{name}\"]").click
end
def suggestion_name(index)
- suggestion = find("#{MENU_SELECTOR} li[data-value=\"#{index}\"]")
+ suggestion = find("#{MENU_SELECTOR} button[data-value=\"#{index}\"]")
suggestion["data-name"]
end