FEATURE: AI suggestion buttons in `move-to-topic` modal (#360)
This commit is contained in:
parent
83744bf192
commit
d674e47ca4
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue