From 9cd14b0003700d89e1a499b9e28924ef8e1443de Mon Sep 17 00:00:00 2001 From: Keegan George Date: Fri, 13 Sep 2024 11:59:30 -0700 Subject: [PATCH] DEV: Move composer AI helper to toolbar (#796) Previously we had moved the AI helper from the options menu to a selection menu that appears when selecting text in the composer. This had the benefit of making the AI helper a more discoverable feature. Now that some time has passed and the AI helper is more recognized, we will be moving it back to the composer toolbar. This is better because: - It consistent with other behavior and ways of accessing tools in the composer - It has an improved mobile experience - It reduces unnecessary code and keeps things easier to migrate when we have composer V2. - It allows for easily triggering AI helper for all content by clicking the button instead of having to select everything. --- .../components/ai-composer-helper-menu.gjs | 293 ++---------------- .../components/ai-helper-button-group.gjs | 19 -- .../components/ai-helper-custom-prompt.gjs | 8 +- .../components/ai-helper-options-list.gjs | 63 ++-- .../components/ai-post-helper-menu.gjs | 1 + .../discourse/components/modal/diff-modal.gjs | 94 +++--- .../modal/thumbnail-suggestions.gjs | 51 ++- .../ai-category-suggestion.gjs | 9 +- .../ai-tag-suggestion.gjs | 9 +- .../ai-title-suggestion.gjs | 9 +- .../after-d-editor/ai-composer-helper.gjs | 221 ------------- .../discourse/lib/show-ai-helper.js | 17 +- .../discourse/services/ai-composer-helper.js | 14 - assets/javascripts/initializers/ai-helper.js | 107 +++++-- .../modules/ai-helper/common/ai-helper.scss | 39 ++- .../ai-helper/mobile/ai-helper-fk-modals.scss | 25 +- .../modules/ai-helper/mobile/ai-helper.scss | 87 ------ config/locales/client.en.yml | 13 +- plugin.rb | 1 - .../ai_helper/ai_composer_helper_spec.rb | 236 ++++---------- spec/system/ai_helper/ai_proofreading_spec.rb | 61 ++-- .../components/ai_composer_helper_menu.rb | 35 --- spec/system/page_objects/modals/diff_modal.rb | 4 +- 23 files changed, 388 insertions(+), 1028 deletions(-) delete mode 100644 assets/javascripts/discourse/components/ai-helper-button-group.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-d-editor/ai-composer-helper.gjs delete mode 100644 assets/javascripts/discourse/services/ai-composer-helper.js delete mode 100644 assets/stylesheets/modules/ai-helper/mobile/ai-helper.scss diff --git a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs index 41fc63fb..4806ed8e 100644 --- a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs +++ b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs @@ -2,15 +2,7 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import { service } from "@ember/service"; -import { modifier } from "ember-modifier"; -import { eq } from "truth-helpers"; -import DButton from "discourse/components/d-button"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { bind } from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; -import AiHelperButtonGroup from "../components/ai-helper-button-group"; -import AiHelperLoading from "../components/ai-helper-loading"; import AiHelperOptionsList from "../components/ai-helper-options-list"; import ModalDiffModal from "../components/modal/diff-modal"; import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions"; @@ -18,30 +10,14 @@ import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions"; export default class AiComposerHelperMenu extends Component { @service modal; @service siteSettings; - @service aiComposerHelper; @service currentUser; - @service capabilities; + @service site; @tracked newSelectedText; @tracked diff; - @tracked initialValue = ""; @tracked customPromptValue = ""; - @tracked loading = false; - @tracked lastUsedOption = null; - @tracked thumbnailSuggestions = null; - @tracked showThumbnailModal = false; - @tracked lastSelectionRange = null; - MENU_STATES = this.aiComposerHelper.MENU_STATES; prompts = []; promptTypes = {}; - documentListeners = modifier(() => { - document.addEventListener("keydown", this.onKeyDown, { passive: true }); - - return () => { - document.removeEventListener("keydown", this.onKeyDown); - }; - }); - get helperOptions() { let prompts = this.currentUser?.ai_helper_prompts; @@ -94,260 +70,51 @@ export default class AiComposerHelperMenu extends Component { return prompts; } - get reviewButtons() { - return [ - { - icon: "exchange-alt", - label: "discourse_ai.ai_helper.context_menu.view_changes", - action: () => - this.modal.show(ModalDiffModal, { - model: { - diff: this.diff, - oldValue: this.initialValue, - newValue: this.newSelectedText, - revert: this.undoAiAction, - confirm: () => this.updateMenuState(this.MENU_STATES.resets), - }, - }), - classes: "view-changes", - }, - { - icon: "undo", - label: "discourse_ai.ai_helper.context_menu.revert", - action: this.undoAiAction, - classes: "revert", - }, - { - icon: "check", - label: "discourse_ai.ai_helper.context_menu.confirm", - action: () => this.updateMenuState(this.MENU_STATES.resets), - classes: "confirm", - }, - ]; - } - - get resetButtons() { - return [ - { - icon: "undo", - label: "discourse_ai.ai_helper.context_menu.undo", - action: this.undoAiAction, - classes: "undo", - }, - { - icon: "discourse-sparkles", - label: "discourse_ai.ai_helper.context_menu.regen", - action: () => this.updateSelected(this.lastUsedOption), - classes: "regenerate", - }, - ]; - } - - get canCloseMenu() { - if ( - document.activeElement === - document.querySelector(".ai-custom-prompt__input") - ) { - return false; - } - - if (this.loading && this._activeAiRequest !== null) { - return false; - } - - if (this.aiComposerHelper.menuState === this.MENU_STATES.review) { - return false; - } - - return true; - } - - get isExpanded() { - if (this.aiComposerHelper.menuState === this.MENU_STATES.triggers) { - return ""; - } - - return "is-expanded"; - } - - @bind - onKeyDown(event) { - if (event.key === "Escape") { - return this.closeMenu(); - } - if ( - event.key === "Backspace" && - this.args.data.selectedText && - this.aiComposerHelper.menuState === this.MENU_STATES.triggers - ) { - return this.closeMenu(); - } - } - @action - toggleAiHelperOptions() { - this.updateMenuState(this.MENU_STATES.options); - } - - @action - async updateSelected(option) { - this._toggleLoadingState(true); - this.lastUsedOption = option; - this.updateMenuState(this.MENU_STATES.loading); - this.initialValue = this.args.data.selectedText; - this.lastSelectionRange = this.args.data.selectionRange; - - try { - this._activeAiRequest = await ajax("/discourse-ai/ai-helper/suggest", { - method: "POST", - data: { + suggestChanges(option) { + if (option.name === "illustrate_post") { + this.modal.show(ThumbnailSuggestion, { + model: { mode: option.id, - text: this.args.data.selectedText, - custom_prompt: this.customPromptValue, - force_default_locale: true, + selectedText: this.args.data.selectedText, + thumbnails: this.thumbnailSuggestions, }, }); - - const data = await this._activeAiRequest; - - // resets the values if new suggestion is started: - this.diff = null; - this.newSelectedText = null; - this.thumbnailSuggestions = null; - - if (option.name === "illustrate_post") { - this._toggleLoadingState(false); - this.closeMenu(); - this.thumbnailSuggestions = data.thumbnails; - this.modal.show(ThumbnailSuggestion, { - model: { - thumbnails: this.thumbnailSuggestions, - }, - }); - } else { - this._updateSuggestedByAi(data); - } - } catch (error) { - popupAjaxError(error); - } finally { - this._toggleLoadingState(false); + return this.args.close(); } - return this._activeAiRequest; - } - - @action - cancelAiAction() { - if (this._activeAiRequest) { - this._activeAiRequest.abort(); - this._activeAiRequest = null; - this._toggleLoadingState(false); - this.closeMenu(); - } - } - - @action - updateMenuState(newState) { - this.aiComposerHelper.menuState = newState; + this.modal.show(ModalDiffModal, { + model: { + mode: option.id, + selectedText: this.args.data.selectedText, + revert: this.undoAiAction, + toolbarEvent: this.args.data.toolbarEvent, + customPromptValue: this.customPromptValue, + }, + }); + return this.args.close(); } @action closeMenu() { - if (!this.canCloseMenu) { - return; - } - this.customPromptValue = ""; - this.updateMenuState(this.MENU_STATES.triggers); this.args.close(); } - @action - undoAiAction() { - if (this.capabilities.isFirefox) { - // execCommand("undo") is no not supported in Firefox so we insert old text at range - // we also need to calculate the length diffrence between the old and new text - const lengthDifference = - this.args.data.selectedText.length - this.initialValue.length; - const end = this.lastSelectionRange.y - lengthDifference; - this._insertAt(this.lastSelectionRange.x, end, this.initialValue); - } else { - document.execCommand("undo", false, null); - } - - // context menu is prevented from closing when in review state - // so we change to reset state quickly before closing - this.updateMenuState(this.MENU_STATES.resets); - this.closeMenu(); - } - - _toggleLoadingState(loading) { - if (loading) { - this.args.data.dEditorInput.classList.add("loading"); - return (this.loading = true); - } - - this.args.data.dEditorInput.classList.remove("loading"); - this.loading = false; - } - - _updateSuggestedByAi(data) { - this.newSelectedText = data.suggestions[0]; - - if (data.diff) { - this.diff = data.diff; - } - - this._insertAt( - this.args.data.selectionRange.x, - this.args.data.selectionRange.y, - this.newSelectedText - ); - - this.updateMenuState(this.MENU_STATES.review); - } - - _insertAt(start, end, text) { - this.args.data.dEditorInput.setSelectionRange(start, end); - this.args.data.dEditorInput.focus(); - document.execCommand("insertText", false, text); - } - } diff --git a/assets/javascripts/discourse/components/ai-helper-button-group.gjs b/assets/javascripts/discourse/components/ai-helper-button-group.gjs deleted file mode 100644 index 73dbcb2a..00000000 --- a/assets/javascripts/discourse/components/ai-helper-button-group.gjs +++ /dev/null @@ -1,19 +0,0 @@ -import DButton from "discourse/components/d-button"; -import concatClass from "discourse/helpers/concat-class"; - -const AiHelperButtonGroup = ; - -export default AiHelperButtonGroup; diff --git a/assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs b/assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs index 08a5a29f..272dcf8f 100644 --- a/assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs +++ b/assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs @@ -4,7 +4,6 @@ import { on } from "@ember/modifier"; import { action } from "@ember/object"; import DButton from "discourse/components/d-button"; import withEventValue from "discourse/helpers/with-event-value"; -import autoFocus from "discourse/modifiers/auto-focus"; import i18n from "discourse-common/helpers/i18n"; import not from "truth-helpers/helpers/not"; @@ -29,12 +28,7 @@ export default class AiHelperCustomPrompt extends Component { }} class="ai-custom-prompt__input" type="text" - {{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus" - because built in autofocus doesn't work consistently when component is - invoked twice separetly without a page refresh. - (i.e. trigger in post AI helper followed by trigger in composer AI helper) - --}} - {{autoFocus}} + autofocus="autofocus" /> - -; +export default class AiHelperOptionsList extends Component { + @service site; -export default AiHelperOptionsList; + get showShortcut() { + return this.site.desktopView && this.args.shortcutVisible; + } + + +} diff --git a/assets/javascripts/discourse/components/ai-post-helper-menu.gjs b/assets/javascripts/discourse/components/ai-post-helper-menu.gjs index 2a9c36f6..3b8cedff 100644 --- a/assets/javascripts/discourse/components/ai-post-helper-menu.gjs +++ b/assets/javascripts/discourse/components/ai-post-helper-menu.gjs @@ -295,6 +295,7 @@ export default class AiPostHelperMenu extends Component { @options={{this.helperOptions}} @customPromptValue={{this.customPromptValue}} @performAction={{this.performAiSuggestion}} + @shortcutVisible={{false}} /> {{else if (eq this.menuState this.MENU_STATES.loading)}} diff --git a/assets/javascripts/discourse/components/modal/diff-modal.gjs b/assets/javascripts/discourse/components/modal/diff-modal.gjs index 34f80953..aea982db 100644 --- a/assets/javascripts/discourse/components/modal/diff-modal.gjs +++ b/assets/javascripts/discourse/components/modal/diff-modal.gjs @@ -1,9 +1,9 @@ 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 ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; import CookText from "discourse/components/cook-text"; import DButton from "discourse/components/d-button"; import DModal from "discourse/components/d-modal"; @@ -15,30 +15,24 @@ export default class ModalDiffModal extends Component { @service currentUser; @tracked loading = false; @tracked diff; - suggestion = ""; - - PROOFREAD_ID = -303; + @tracked suggestion = ""; constructor() { super(...arguments); - this.diff = this.args.model.diff; - - next(() => { - if (this.args.model.toolbarEvent) { - this.loadDiff(); - } - }); + this.suggestChanges(); } - async loadDiff() { + @action + async suggestChanges() { this.loading = true; try { const suggestion = await ajax("/discourse-ai/ai-helper/suggest", { method: "POST", data: { - mode: this.PROOFREAD_ID, - text: this.selectedText, + mode: this.args.model.mode, + text: this.args.model.selectedText, + custom_prompt: this.args.model.customPromptValue, force_default_locale: true, }, }); @@ -52,37 +46,18 @@ export default class ModalDiffModal extends Component { } } - 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(); - if (this.args.model.confirm) { - this.args.model.confirm(); - } - if (this.args.model.toolbarEvent && this.suggestion) { + if (this.suggestion) { this.args.model.toolbarEvent.replaceText( - this.selectedText, + this.args.model.selectedText, this.suggestion ); } } - @action - triggerRevertChanges() { - this.args.model.revert(); - this.args.closeModal(); - } -