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