FEATURE: AI suggestion buttons in `move-to-topic` modal (#360)

This commit is contained in:
Keegan George 2023-12-15 12:11:14 -08:00 committed by GitHub
parent 83744bf192
commit d674e47ca4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 472 additions and 0 deletions

View File

@ -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();
}
}
<template>
{{#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}}
<DButton
class="ai-split-topic-loading-placeholder"
@disabled={{true}}
@icon="spinner"
/>
{{/if}}
<DMenu
@icon="discourse-sparkles"
@interactive={{true}}
@identifier="ai-split-topic-suggestion-menu"
class="ai-split-topic-suggestion-button"
data-suggestion-mode={{@mode}}
{{on "click" this.loadSuggestions}}
as |menu|
>
<ul class="ai-split-topic-suggestion__results">
{{#unless this.loading}}
{{#each this.suggestions as |suggestion index|}}
{{#if (eq @mode "suggest_category")}}
<li
data-name={{suggestion.name}}
data-value={{suggestion.id}}
class="ai-split-topic-suggestion__category-result"
role="button"
{{on "click" (fn this.applySuggestion suggestion menu)}}
>
{{categoryBadge suggestion}}
</li>
{{else}}
<li data-name={{suggestion}} data-value={{index}}>
<DButton
@translatedLabel={{suggestion}}
@action={{fn this.applySuggestion suggestion menu}}
/>
</li>
{{/if}}
{{/each}}
{{/unless}}
</ul>
</DMenu>
</template>
}

View File

@ -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;
<template>
{{#if this.siteSettings.ai_embeddings_enabled}}
<AiSplitTopicSuggester
@selectedPosts={{@outletArgs.selectedPosts}}
@mode="suggest_category"
@updateAction={{@outletArgs.updateCategoryId}}
/>
{{/if}}
</template>
}

View File

@ -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;
<template>
{{#if this.siteSettings.ai_embeddings_enabled}}
<AiSplitTopicSuggester
@selectedPosts={{@outletArgs.selectedPosts}}
@mode="suggest_tags"
@updateAction={{@outletArgs.updateTags}}
@currentValue={{@outletArgs.tags}}
/>
{{/if}}
</template>
}

View File

@ -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);
}
<template>
<AiSplitTopicSuggester
@selectedPosts={{@outletArgs.selectedPosts}}
@mode="suggest_title"
@updateAction={{@outletArgs.updateTopicName}}
/>
</template>
}

View File

@ -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;
}
}

View File

@ -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
"<item>Pie: A delicious dessert</item><item>Cake is the best!</item><item>Croissants are delightful</item><item>Some great desserts</item><item>What is the best dessert?</item>"
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

View File

@ -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