diff --git a/assets/javascripts/discourse/components/modal/diff-modal.gjs b/assets/javascripts/discourse/components/modal/diff-modal.gjs index 66cba0f0..34f80953 100644 --- a/assets/javascripts/discourse/components/modal/diff-modal.gjs +++ b/assets/javascripts/discourse/components/modal/diff-modal.gjs @@ -1,15 +1,80 @@ import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; +import { next } from "@ember/runloop"; +import { inject as service } from "@ember/service"; import { htmlSafe } from "@ember/template"; +import CookText from "discourse/components/cook-text"; import DButton from "discourse/components/d-button"; import DModal from "discourse/components/d-modal"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; import i18n from "discourse-common/helpers/i18n"; export default class ModalDiffModal extends Component { + @service currentUser; + @tracked loading = false; + @tracked diff; + suggestion = ""; + + PROOFREAD_ID = -303; + + constructor() { + super(...arguments); + this.diff = this.args.model.diff; + + next(() => { + if (this.args.model.toolbarEvent) { + this.loadDiff(); + } + }); + } + + async loadDiff() { + this.loading = true; + + try { + const suggestion = await ajax("/discourse-ai/ai-helper/suggest", { + method: "POST", + data: { + mode: this.PROOFREAD_ID, + text: this.selectedText, + force_default_locale: true, + }, + }); + + this.diff = suggestion.diff; + this.suggestion = suggestion.suggestions[0]; + } catch (e) { + popupAjaxError(e); + } finally { + this.loading = false; + } + } + + get selectedText() { + const selected = this.args.model.toolbarEvent.selected; + + if (selected.value === "") { + return selected.pre + selected.post; + } + + return selected.value; + } + @action triggerConfirmChanges() { this.args.closeModal(); - this.args.model.confirm(); + if (this.args.model.confirm) { + this.args.model.confirm(); + } + + if (this.args.model.toolbarEvent && this.suggestion) { + this.args.model.toolbarEvent.replaceText( + this.selectedText, + this.suggestion + ); + } } @action @@ -25,30 +90,46 @@ export default class ModalDiffModal extends Component { @closeModal={{@closeModal}} > <:body> - {{#if @model.diff}} - {{htmlSafe @model.diff}} + {{#if this.loading}} +
+ +
{{else}} -
- {{@model.oldValue}} -
+ {{#if this.diff}} + {{htmlSafe this.diff}} + {{else}} +
+ {{@model.oldValue}} +
-
- {{@model.newValue}} -
+
+ {{@model.newValue}} +
+ {{/if}} {{/if}} <:footer> - - + {{#if this.loading}} + + {{else}} + + {{#if @model.revert}} + + {{/if}} + {{/if}} diff --git a/assets/javascripts/initializers/ai-helper.js b/assets/javascripts/initializers/ai-helper.js new file mode 100644 index 00000000..acaffa35 --- /dev/null +++ b/assets/javascripts/initializers/ai-helper.js @@ -0,0 +1,37 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import ModalDiffModal from "../discourse/components/modal/diff-modal"; + +function initializeProofread(api) { + api.addComposerToolbarPopupMenuOption({ + action: (toolbarEvent) => { + const modal = api.container.lookup("service:modal"); + + modal.show(ModalDiffModal, { + model: { + toolbarEvent, + }, + }); + }, + icon: "spell-check", + label: "discourse_ai.ai_helper.context_menu.proofread_prompt", + shortcut: "ALT+P", + condition: () => { + const siteSettings = api.container.lookup("service:site-settings"); + const currentUser = api.getCurrentUser(); + + return ( + siteSettings.ai_helper_enabled && currentUser?.can_use_assistant_in_post + ); + }, + }); +} + +export default { + name: "discourse-ai-helper", + + initialize() { + withPluginApi("1.1.0", (api) => { + initializeProofread(api); + }); + }, +}; diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index 1af920d5..99aa408f 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -14,6 +14,18 @@ height: 200px; } } + @keyframes fadeOpacity { + 0% { + opacity: 1; + } + 100% { + opacity: 0.5; + } + } + + &__loading { + animation: fadeOpacity 1.5s infinite alternate; + } &__old-value { background-color: var(--danger-low); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 03b5c612..faa9c1ea 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -236,7 +236,7 @@ en: back: "Back" confirm_delete: Are you sure you want to delete this model? delete: Delete - in_use_warning: + in_use_warning: one: "This model is currently used by the %{settings} setting. If misconfigured, the feature won't work as expected." other: "This model is currently used by the following settings: %{settings}. If misconfigured, features won't work as expected. " @@ -294,12 +294,13 @@ en: view_changes: "View Changes" confirm: "Confirm" revert: "Revert" - changes: "Changes" + changes: "Suggested Edits" custom_prompt: title: "Custom Prompt" placeholder: "Enter a custom prompt..." submit: "Send Prompt" translate_prompt: "Translate to %{language}" + proofread_prompt: "Proofread Text" post_options_menu: trigger: "Ask AI" title: "Ask AI" diff --git a/spec/system/ai_helper/ai_proofreading_spec.rb b/spec/system/ai_helper/ai_proofreading_spec.rb new file mode 100644 index 00000000..3792b470 --- /dev/null +++ b/spec/system/ai_helper/ai_proofreading_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do + fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) } + + before do + assign_fake_provider_to(:ai_helper_model) + SiteSetting.ai_helper_enabled = true + sign_in(admin) + end + + let(:composer) { PageObjects::Components::Composer.new } + + it "proofreads selected text using the composer toolbar" do + visit "/new-topic" + composer.fill_content("hello worldd !") + + composer.select_range(6, 12) + + DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do + ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options") + ai_toolbar.expand + ai_toolbar.select_row_by_name("Proofread Text") + + find(".composer-ai-helper-modal .btn-primary.confirm").click + expect(composer.composer_input.value).to eq("hello world !") + end + end + + it "proofreads all text when nothing is selected" do + visit "/new-topic" + composer.fill_content("hello worrld") + + # Simulate AI response + DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do + ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options") + ai_toolbar.expand + ai_toolbar.select_row_by_name("Proofread Text") + + find(".composer-ai-helper-modal .btn-primary.confirm").click + expect(composer.composer_input.value).to eq("hello world") + end + end +end