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); - } - - -} 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"; + } + + +} 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"; + } + } + + +} 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"; + } + + +} 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; - } 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; - } 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