diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb index ec9863fe..5d987e62 100644 --- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb +++ b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb @@ -9,9 +9,11 @@ module DiscourseAi before_action :rate_limiter_performed!, except: %i[prompts] def prompts + name_filter = params[:name_filter] + render json: ActiveModel::ArraySerializer.new( - DiscourseAi::AiHelper::Assistant.new.available_prompts, + DiscourseAi::AiHelper::Assistant.new.available_prompts(name_filter: name_filter), root: false, ), status: 200 diff --git a/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs b/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs new file mode 100644 index 00000000..e3766b2d --- /dev/null +++ b/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs @@ -0,0 +1,80 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { showPostAIHelper } from "../../lib/show-ai-helper"; + +export default class AiEditSuggestionButton extends Component { + static shouldRender(outletArgs, helper) { + return showPostAIHelper(outletArgs, helper); + } + + @tracked loading = false; + @tracked suggestion = ""; + @tracked _activeAIRequest = null; + + constructor() { + super(...arguments); + + if (!this.mode) { + this.loadMode(); + } + } + + get disabled() { + return ( + this.loading || + this.suggestion?.length > 0 || + this.args.outletArgs.newValue + ); + } + + async loadMode() { + let mode = await ajax("/discourse-ai/ai-helper/prompts", { + method: "GET", + data: { + name_filter: "proofread", + }, + }); + + this.mode = mode[0]; + } + + @action + suggest() { + this.loading = true; + this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", { + method: "POST", + data: { + mode: this.mode.id, + text: this.args.outletArgs.initialValue, + custom_prompt: "", + }, + }); + + this._activeAIRequest + .then(({ suggestions }) => { + this.suggestion = suggestions[0].trim(); + this.args.outletArgs.updateValue(this.suggestion); + }) + .catch(popupAjaxError) + .finally(() => { + this.loading = false; + }); + + return this._activeAIRequest; + } + + +} diff --git a/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs b/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs index a6b106e5..3a7e5e9f 100644 --- a/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs +++ b/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs @@ -5,6 +5,8 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import { inject as service } from "@ember/service"; import DButton from "discourse/components/d-button"; +import FastEdit from "discourse/components/fast-edit"; +import FastEditModal from "discourse/components/modal/fast-edit"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { cook } from "discourse/lib/text"; @@ -20,6 +22,8 @@ export default class AIHelperOptionsMenu extends Component { return showPostAIHelper(outletArgs, helper); } @service messageBus; + @service site; + @service modal; @service siteSettings; @service currentUser; @tracked helperOptions = []; @@ -30,6 +34,8 @@ export default class AIHelperOptionsMenu extends Component { @tracked customPromptValue = ""; @tracked copyButtonIcon = "copy"; @tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy"; + @tracked showFastEdit = false; + @tracked showAiButtons = true; MENU_STATES = { triggers: "TRIGGERS", @@ -83,7 +89,7 @@ export default class AIHelperOptionsMenu extends Component { async performAISuggestion(option) { this.menuState = this.MENU_STATES.loading; - if (option.name === "Explain") { + if (option.name === "explain") { this.menuState = this.MENU_STATES.result; const fetchUrl = `/discourse-ai/ai-helper/explain`; @@ -106,10 +112,28 @@ export default class AIHelperOptionsMenu extends Component { }); } - if (option.name !== "Explain") { + if (option.name !== "explain") { this._activeAIRequest .then(({ suggestions }) => { - this.suggestion = suggestions[0]; + this.suggestion = suggestions[0].trim(); + + if (option.name === "proofread") { + this.showAiButtons = false; + + if (this.site.desktopView) { + this.showFastEdit = true; + return; + } else { + return this.modal.show(FastEditModal, { + model: { + initialValue: this.args.outletArgs.data.quoteState.buffer, + newValue: this.suggestion, + post: this.args.outletArgs.post, + close: this.closeFastEdit, + }, + }); + } + } }) .catch(popupAjaxError) .finally(() => { @@ -166,6 +190,10 @@ export default class AIHelperOptionsMenu extends Component { prompts = prompts.filter((p) => p.name !== "custom_prompt"); } + if (!this.args.outletArgs.data.canEditPost) { + prompts = prompts.filter((p) => p.name !== "proofread"); + } + this.helperOptions = prompts; } @@ -178,75 +206,95 @@ export default class AIHelperOptionsMenu extends Component { return this.currentUser?.groups.some((g) => allowedGroups.includes(g.id)); } + @action + async closeFastEdit() { + this.showFastEdit = false; + await this.args.outletArgs.data.hideToolbar(); + } + } diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index 4361d8a5..a6dd42a4 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -343,4 +343,10 @@ } } } + + &__fast-edit { + .fast-edit-container { + padding-top: 0.5em; + } + } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 29ef2bd2..3c813317 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -104,6 +104,8 @@ en: copy: "Copy" copied: "Copied!" cancel: "Cancel" + fast_edit: + suggest_button: "Suggest Edit" reviewables: model_used: "Model used:" accuracy: "Accuracy:" diff --git a/lib/ai_helper/assistant.rb b/lib/ai_helper/assistant.rb index 573fec35..24e4758a 100644 --- a/lib/ai_helper/assistant.rb +++ b/lib/ai_helper/assistant.rb @@ -126,7 +126,7 @@ module DiscourseAi when "generate_titles" %w[composer] when "proofread" - %w[composer] + %w[composer post] when "markdown_table" %w[composer] when "tone" diff --git a/spec/requests/ai_helper/assistant_controller_spec.rb b/spec/requests/ai_helper/assistant_controller_spec.rb index 42ea139e..78939334 100644 --- a/spec/requests/ai_helper/assistant_controller_spec.rb +++ b/spec/requests/ai_helper/assistant_controller_spec.rb @@ -108,4 +108,50 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do end end end + + describe "#prompts" do + context "when not logged in" do + it "returns a 403 response" do + get "/discourse-ai/ai-helper/prompts" + expect(response.status).to eq(403) + end + end + + context "when logged in as a user without enough privileges" do + fab!(:user) { Fabricate(:newuser) } + + before do + sign_in(user) + SiteSetting.ai_helper_allowed_groups = Group::AUTO_GROUPS[:staff] + end + + it "returns a 403 response" do + get "/discourse-ai/ai-helper/prompts" + expect(response.status).to eq(403) + end + end + + context "when logged in as an allowed user" do + fab!(:user) { Fabricate(:user) } + + before do + sign_in(user) + user.group_ids = [Group::AUTO_GROUPS[:trust_level_1]] + SiteSetting.ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_1] + end + + it "returns a list of prompts when no name_filter is provided" do + get "/discourse-ai/ai-helper/prompts" + expect(response.status).to eq(200) + expect(response.parsed_body.length).to eq(6) + end + + it "returns a list with with filtered prompts when name_filter is provided" do + get "/discourse-ai/ai-helper/prompts", params: { name_filter: "proofread" } + expect(response.status).to eq(200) + expect(response.parsed_body.length).to eq(1) + expect(response.parsed_body.first["name"]).to eq("proofread") + end + end + end end diff --git a/spec/system/ai_helper/ai_post_helper_spec.rb b/spec/system/ai_helper/ai_post_helper_spec.rb index e2335b28..5b820e80 100644 --- a/spec/system/ai_helper/ai_post_helper_spec.rb +++ b/spec/system/ai_helper/ai_post_helper_spec.rb @@ -15,8 +15,16 @@ RSpec.describe "AI Post helper", type: :system, js: true do fab!(:post_2) do Fabricate(:post, topic: topic, raw: "La lluvia en España se queda principalmente en el avión.") end + fab!(:post_3) do + Fabricate( + :post, + topic: topic, + raw: "The Toyota Supra delivrs 382 horsepwr makin it a very farst car.", + ) + end let(:topic_page) { PageObjects::Pages::Topic.new } let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new } + let(:fast_editor) { PageObjects::Components::FastEditor.new } before do Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) @@ -77,18 +85,34 @@ RSpec.describe "AI Post helper", type: :system, js: true do let(:translated_input) { "The rain in Spain, stays mainly in the Plane." } - skip "TODO: Fix explain option stuck in loading in test" do - it "shows a translation of the selected text" do - select_post_text(post_2) - post_ai_helper.click_ai_button + it "shows a translation of the selected text" do + select_post_text(post_2) + post_ai_helper.click_ai_button - DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do - post_ai_helper.select_helper_model(mode) + DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do + post_ai_helper.select_helper_model(mode) - wait_for { post_ai_helper.suggestion_value == translated_input } + wait_for { post_ai_helper.suggestion_value == translated_input } - expect(post_ai_helper.suggestion_value).to eq(translated_input) - end + expect(post_ai_helper.suggestion_value).to eq(translated_input) + end + end + end + + context "when using proofread mode" do + let(:mode) { CompletionPrompt::PROOFREAD } + let(:proofread_response) do + "The Toyota Supra delivers 382 horsepower making it a very fast car." + end + + it "pre-fills fast edit with proofread text" do + skip("Test is flaky in CI, possibly some timing issue?") if ENV["CI"] + select_post_text(post_3) + post_ai_helper.click_ai_button + DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do + post_ai_helper.select_helper_model(mode) + wait_for { fast_editor.has_content?(proofread_response) } + expect(fast_editor).to have_content(proofread_response) end end end @@ -116,4 +140,21 @@ RSpec.describe "AI Post helper", type: :system, js: true do expect(post_ai_helper).to have_no_post_ai_helper end end + + context "when triggering AI proofread through edit button" do + let(:proofread_response) do + "The Toyota Supra delivers 382 horsepower making it a very fast car." + end + + it "pre-fills fast edit with proofread text" do + skip("Test is flaky in CI, possibly some timing issue?") if ENV["CI"] + select_post_text(post_3) + find(".quote-edit-label").click + DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do + find(".btn-ai-suggest-edit", visible: :all).click + wait_for { fast_editor.has_content?(proofread_response) } + expect(fast_editor).to have_content(proofread_response) + end + end + end end