diff --git a/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs index b92abd66..df6dd195 100644 --- a/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs +++ b/assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs @@ -131,6 +131,7 @@ export default class AISuggestionDropdown extends Component { data: { text: this.composer.model.reply }, }) .then((data) => { + console.log(data); this.#assignGeneratedSuggestions(data, this.args.mode); }) .catch(popupAjaxError) @@ -198,6 +199,7 @@ export default class AISuggestionDropdown extends Component { } const suggestions = data.assistant.map((s) => s.name); + // console.log("suggest", suggestions); if (mode === this.SUGGESTION_TYPES.tag) { if (this.#tagSelectorHasValues()) { 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..5b04097d --- /dev/null +++ b/assets/javascripts/discourse/components/suggestion-menus/ai-category-suggester.gjs @@ -0,0 +1,142 @@ +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 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"; + +export default class AiCategorySuggester extends Component { + @service siteSettings; + @tracked loading = false; + @tracked suggestions = null; + @tracked untriggers = []; + @tracked triggerIcon = "discourse-sparkles"; + + get referenceText() { + if (this.args.composer?.reply) { + return this.args.composer.reply; + } + + console.log(this.args); + ajax(`/raw/${this.args.topic.id}/1.json`).then((response) => { + console.log(response); + }); + + return "abcdefhg"; + } + + get showSuggestionButton() { + const MIN_CHARACTER_COUNT = 40; + const composerFields = document.querySelector(".composer-fields"); + const showTrigger = this.referenceText.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.args.composer.reply }, + } + ); + 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; + if (!composer) { + return; + } + + composer.set("categoryId", suggestion.id); + 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 f8f97c57..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 { inject as 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 d0748800..21ce47bf 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,16 @@ 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 AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; +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 { showComposerAiHelper } from "../../lib/show-ai-helper"; export default class AiTagSuggestion extends Component { @@ -14,14 +24,188 @@ export default class AiTagSuggestion extends Component { } @service siteSettings; + @service toasts; + @tracked loading = false; + @tracked suggestions = null; + @tracked untriggers = []; + @tracked triggerIcon = "discourse-sparkles"; + + get showSuggestionButton() { + const MIN_CHARACTER_COUNT = 40; + const composerFields = document.querySelector(".composer-fields"); + const showTrigger = + this.args.outletArgs.composer.reply?.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.args.outletArgs.composer.reply }, + }); + this.suggestions = assistant; + + if (this.#tagSelectorHasValues()) { + this.suggestions = this.suggestions.filter( + (s) => !this.args.outletArgs.composer.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.outletArgs.composer?.tags && + this.args.outletArgs.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 composer = this.args.outletArgs.composer; + if (!composer) { + return; + } + + if (!composer.tags) { + composer.set("tags", [suggestion.name]); + this.#removedAppliedTag(suggestion); + return; + } + + const tags = composer.tags; + + 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); + composer.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/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..379345b0 --- /dev/null +++ b/assets/javascripts/discourse/connectors/edit-topic-category__after/ai-category-suggestion.gjs @@ -0,0 +1,23 @@ +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/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index 8c8d1265..f01d5488 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -228,25 +228,32 @@ 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 { + 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; + } + } } // Prevent suggestion button from wrapping diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 22c5f38f..613e4b04 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -340,6 +340,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