mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-02-08 12:34:43 +00:00
Previous to this change we relied on explicit loading for a files in Discourse AI. This had a few downsides: - Busywork whenever you add a file (an extra require relative) - We were not keeping to conventions internally ... some places were OpenAI others are OpenAi - Autoloader did not work which lead to lots of full application broken reloads when developing. This moves all of DiscourseAI into a Zeitwerk compatible structure. It also leaves some minimal amount of manual loading (automation - which is loading into an existing namespace that may or may not be there) To avoid needing /lib/discourse_ai/... we mount a namespace thus we are able to keep /lib pointed at ::DiscourseAi Various files were renamed to get around zeitwerk rules and minimize usage of custom inflections Though we can get custom inflections to work it is not worth it, will require a Discourse core patch which means we create a hard dependency.
415 lines
14 KiB
Ruby
415 lines
14 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
RSpec.describe "AI Composer helper", type: :system, js: true do
|
||
fab!(:user) { Fabricate(:admin) }
|
||
fab!(:non_member_group) { Fabricate(:group) }
|
||
|
||
before do
|
||
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
|
||
SiteSetting.composer_ai_helper_enabled = true
|
||
sign_in(user)
|
||
end
|
||
|
||
let(:input) { "The rain in spain stays mainly in the Plane." }
|
||
|
||
let(:composer) { PageObjects::Components::Composer.new }
|
||
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
|
||
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
||
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
||
fab!(:category) { Fabricate(:category) }
|
||
fab!(:category_2) { Fabricate(:category) }
|
||
fab!(:video) { Fabricate(:tag) }
|
||
fab!(:music) { Fabricate(:tag) }
|
||
fab!(:cloud) { Fabricate(:tag) }
|
||
fab!(:feedback) { Fabricate(:tag) }
|
||
fab!(:review) { Fabricate(:tag) }
|
||
|
||
def trigger_context_menu(content)
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(content)
|
||
page.execute_script("document.querySelector('.d-editor-input')?.select();")
|
||
end
|
||
|
||
context "when triggering AI with context menu in composer" do
|
||
it "shows the context menu when selecting a passage of text in the composer" do
|
||
trigger_context_menu(input)
|
||
expect(ai_helper_context_menu).to have_context_menu
|
||
end
|
||
|
||
it "does not show the context menu when selecting insuffient text" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
page.execute_script(
|
||
"const input = document.querySelector('.d-editor-input'); input.setSelectionRange(0, 2);",
|
||
)
|
||
expect(ai_helper_context_menu).to have_no_context_menu
|
||
end
|
||
|
||
it "shows context menu in 'trigger' state when first showing" do
|
||
trigger_context_menu(input)
|
||
expect(ai_helper_context_menu).to be_showing_triggers
|
||
end
|
||
|
||
it "shows prompt options in context menu when AI button is clicked" do
|
||
trigger_context_menu(input)
|
||
ai_helper_context_menu.click_ai_button
|
||
expect(ai_helper_context_menu).to be_showing_options
|
||
end
|
||
|
||
it "closes the context menu when clicking outside" do
|
||
trigger_context_menu(input)
|
||
find(".d-editor-preview").click
|
||
expect(ai_helper_context_menu).to have_no_context_menu
|
||
end
|
||
|
||
context "when using custom prompt" do
|
||
let(:mode) { CompletionPrompt::CUSTOM_PROMPT }
|
||
|
||
let(:custom_prompt_input) { "Translate to French" }
|
||
let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." }
|
||
|
||
it "shows custom prompt option" do
|
||
trigger_context_menu(input)
|
||
ai_helper_context_menu.click_ai_button
|
||
expect(ai_helper_context_menu).to have_custom_prompt
|
||
end
|
||
|
||
it "enables the custom prompt button when input is filled" do
|
||
trigger_context_menu(input)
|
||
ai_helper_context_menu.click_ai_button
|
||
expect(ai_helper_context_menu).to have_custom_prompt_button_disabled
|
||
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
|
||
expect(ai_helper_context_menu).to have_custom_prompt_button_enabled
|
||
end
|
||
|
||
it "replaces the composed message with AI generated content" do
|
||
trigger_context_menu(input)
|
||
ai_helper_context_menu.click_ai_button
|
||
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([custom_prompt_response]) do
|
||
ai_helper_context_menu.click_custom_prompt_button
|
||
|
||
wait_for { composer.composer_input.value == custom_prompt_response }
|
||
|
||
expect(composer.composer_input.value).to eq(custom_prompt_response)
|
||
end
|
||
end
|
||
end
|
||
|
||
context "when not a member of custom prompt group" do
|
||
let(:mode) { CompletionPrompt::CUSTOM_PROMPT }
|
||
before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s }
|
||
|
||
it "does not show custom prompt option" do
|
||
trigger_context_menu(input)
|
||
ai_helper_context_menu.click_ai_button
|
||
expect(ai_helper_context_menu).to have_no_custom_prompt
|
||
end
|
||
end
|
||
|
||
context "when using translation mode" do
|
||
let(:mode) { CompletionPrompt::TRANSLATE }
|
||
|
||
let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." }
|
||
|
||
it "replaces the composed message with AI generated content" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
expect(composer.composer_input.value).to eq(input)
|
||
end
|
||
end
|
||
|
||
it "shows reset options after results are complete" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
ai_helper_context_menu.click_confirm_button
|
||
expect(ai_helper_context_menu).to be_showing_resets
|
||
end
|
||
end
|
||
|
||
it "reverts results when Undo button is clicked" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
ai_helper_context_menu.click_confirm_button
|
||
ai_helper_context_menu.click_undo_button
|
||
expect(composer.composer_input.value).to eq(spanish_input)
|
||
end
|
||
end
|
||
|
||
it "reverts results when revert button is clicked" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
ai_helper_context_menu.click_revert_button
|
||
expect(composer.composer_input.value).to eq(spanish_input)
|
||
end
|
||
end
|
||
|
||
it "reverts results when Ctrl/Cmd + Z is pressed on the keyboard" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
ai_helper_context_menu.press_undo_keys
|
||
expect(composer.composer_input.value).to eq(spanish_input)
|
||
end
|
||
end
|
||
|
||
it "confirms the results when confirm button is pressed" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
ai_helper_context_menu.click_confirm_button
|
||
expect(composer.composer_input.value).to eq(input)
|
||
end
|
||
end
|
||
|
||
it "hides the context menu when pressing Escape on the keyboard" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
ai_helper_context_menu.press_escape_key
|
||
expect(ai_helper_context_menu).to have_no_context_menu
|
||
end
|
||
|
||
it "shows the changes in a modal when view changes button is pressed" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
ai_helper_context_menu.click_view_changes_button
|
||
expect(diff_modal).to be_visible
|
||
expect(diff_modal.old_value).to eq(spanish_input.gsub(/[[:space:]]+/, " ").strip)
|
||
expect(diff_modal.new_value).to eq(
|
||
input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip,
|
||
)
|
||
diff_modal.confirm_changes
|
||
expect(ai_helper_context_menu).to be_showing_resets
|
||
end
|
||
end
|
||
|
||
it "should not close the context menu when in review state" do
|
||
trigger_context_menu(spanish_input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == input }
|
||
|
||
find(".d-editor-preview").click
|
||
expect(ai_helper_context_menu).to have_context_menu
|
||
end
|
||
end
|
||
end
|
||
|
||
context "when using the proofreading mode" do
|
||
let(:mode) { CompletionPrompt::PROOFREAD }
|
||
|
||
let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." }
|
||
|
||
it "replaces the composed message with AI generated content" do
|
||
trigger_context_menu(input)
|
||
ai_helper_context_menu.click_ai_button
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do
|
||
ai_helper_context_menu.select_helper_model(mode)
|
||
|
||
wait_for { composer.composer_input.value == proofread_text }
|
||
|
||
expect(composer.composer_input.value).to eq(proofread_text)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
context "when suggesting titles with AI title suggester" do
|
||
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
||
|
||
let(:titles) do
|
||
"<item>Rainy Spain</item><item>Plane-Bound Delights</item><item>Mysterious Spain</item><item>Plane-Rain Chronicles</item><item>Unveiling Spain</item>"
|
||
end
|
||
|
||
it "opens a menu with title suggestions" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do
|
||
ai_suggestion_dropdown.click_suggest_titles_button
|
||
|
||
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||
|
||
expect(ai_suggestion_dropdown).to have_dropdown
|
||
end
|
||
end
|
||
|
||
it "replaces the topic title with the selected title" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do
|
||
ai_suggestion_dropdown.click_suggest_titles_button
|
||
|
||
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||
|
||
ai_suggestion_dropdown.select_suggestion_by_value(1)
|
||
expected_title = "Plane-Bound Delights"
|
||
|
||
expect(find("#reply-title").value).to eq(expected_title)
|
||
end
|
||
end
|
||
|
||
it "closes the menu when clicking outside" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
|
||
DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do
|
||
ai_suggestion_dropdown.click_suggest_titles_button
|
||
|
||
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||
|
||
find(".d-editor-preview").click
|
||
|
||
expect(ai_suggestion_dropdown).to have_no_dropdown
|
||
end
|
||
end
|
||
|
||
it "only shows trigger button if there is sufficient content in the composer" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content("abc")
|
||
|
||
expect(ai_suggestion_dropdown).to have_no_suggestion_button
|
||
|
||
composer.fill_content(input)
|
||
expect(ai_suggestion_dropdown).to have_suggestion_button
|
||
end
|
||
end
|
||
|
||
context "when suggesting the category with AI category suggester" do
|
||
before { SiteSetting.ai_embeddings_enabled = true }
|
||
|
||
it "updates the category with the suggested category" do
|
||
response =
|
||
Category
|
||
.take(3)
|
||
.pluck(:slug)
|
||
.map { |s| { name: s, score: rand(0.0...45.0) } }
|
||
.sort { |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)
|
||
category_selector = page.find(".category-chooser summary")
|
||
|
||
expect(category_selector["data-name"]).to eq(suggestion)
|
||
end
|
||
end
|
||
|
||
context "when suggesting the tags with AI tag suggester" do
|
||
before { SiteSetting.ai_embeddings_enabled = true }
|
||
|
||
it "updates 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)
|
||
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
|
||
ai_suggestion_dropdown.click_suggest_tags_button
|
||
|
||
wait_for { ai_suggestion_dropdown.has_dropdown? }
|
||
|
||
suggestion = ai_suggestion_dropdown.suggestion_name(0)
|
||
ai_suggestion_dropdown.select_suggestion_by_value(0)
|
||
tag_selector = page.find(".mini-tag-chooser summary")
|
||
|
||
expect(tag_selector["data-name"]).to eq(suggestion)
|
||
end
|
||
end
|
||
|
||
context "when AI helper is disabled" do
|
||
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
||
before { SiteSetting.composer_ai_helper_enabled = false }
|
||
|
||
it "does not trigger AI context menu" do
|
||
trigger_context_menu(input)
|
||
expect(ai_helper_context_menu).to have_no_context_menu
|
||
end
|
||
|
||
it "does not trigger AI suggestion buttons" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
expect(ai_suggestion_dropdown).to have_no_suggestion_button
|
||
end
|
||
end
|
||
|
||
context "when user is not a member of AI helper allowed group" do
|
||
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
||
before { SiteSetting.ai_helper_allowed_groups = non_member_group.id.to_s }
|
||
|
||
it "does not trigger AI context menu" do
|
||
trigger_context_menu(input)
|
||
expect(ai_helper_context_menu).to have_no_context_menu
|
||
end
|
||
|
||
it "does not trigger AI suggestion buttons" do
|
||
visit("/latest")
|
||
page.find("#create-topic").click
|
||
composer.fill_content(input)
|
||
expect(ai_suggestion_dropdown).to have_no_suggestion_button
|
||
end
|
||
end
|
||
end
|