From 1254d7c7d0bb42dd6b2c649803389e35e1e18523 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Tue, 6 Aug 2024 10:57:39 -0700 Subject: [PATCH] REFACTOR: AI Composer Helper Menu (#715) --- .../components/ai-composer-helper-menu.gjs | 351 ++++++++++++++ .../components/ai-helper-button-group.gjs | 19 + .../components/ai-helper-custom-prompt.gjs | 38 +- .../after-d-editor/ai-composer-helper.gjs | 204 ++++++++ .../after-d-editor/ai-helper-context-menu.hbs | 93 ---- .../after-d-editor/ai-helper-context-menu.js | 444 ------------------ .../lib/virtual-element-from-caret-coords.js | 45 ++ .../discourse/services/ai-composer-helper.js | 14 + .../modules/ai-helper/common/ai-helper.scss | 16 +- .../ai_helper/ai_composer_helper_spec.rb | 18 +- ...ext_menu.rb => ai_composer_helper_menu.rb} | 10 +- 11 files changed, 670 insertions(+), 582 deletions(-) create mode 100644 assets/javascripts/discourse/components/ai-composer-helper-menu.gjs create mode 100644 assets/javascripts/discourse/components/ai-helper-button-group.gjs create mode 100644 assets/javascripts/discourse/connectors/after-d-editor/ai-composer-helper.gjs delete mode 100644 assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.hbs delete mode 100644 assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js create mode 100644 assets/javascripts/discourse/lib/virtual-element-from-caret-coords.js create mode 100644 assets/javascripts/discourse/services/ai-composer-helper.js rename spec/system/page_objects/components/{ai_helper_context_menu.rb => ai_composer_helper_menu.rb} (91%) diff --git a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs new file mode 100644 index 00000000..f5c4a6c3 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs @@ -0,0 +1,351 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +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"; + +export default class AiComposerHelperMenu extends Component { + @service siteSettings; + @service aiComposerHelper; + @service currentUser; + @service capabilities; + @tracked newSelectedText; + @tracked diff; + @tracked initialValue = ""; + @tracked customPromptValue = ""; + @tracked loading = false; + @tracked lastUsedOption = null; + @tracked thumbnailSuggestions = null; + @tracked showThumbnailModal = false; + @tracked showDiffModal = 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; + + prompts = prompts + .filter((p) => p.location.includes("composer")) + .filter((p) => p.name !== "generate_titles") + .map((p) => { + // AI helper by default returns interface locale on translations + // Since we want site default translations (and we are using: force_default_locale) + // we need to replace the translated_name with the site default locale name + const siteLocale = this.siteSettings.default_locale; + const availableLocales = JSON.parse( + this.siteSettings.available_locales + ); + const locale = availableLocales.find((l) => l.value === siteLocale); + const translatedName = I18n.t( + "discourse_ai.ai_helper.context_menu.translate_prompt", + { + language: locale.name, + } + ); + + if (p.name === "translate") { + return { ...p, translated_name: translatedName }; + } + return p; + }); + + // Find the custom_prompt object and move it to the beginning of the array + const customPromptIndex = prompts.findIndex( + (p) => p.name === "custom_prompt" + ); + if (customPromptIndex !== -1) { + const customPrompt = prompts.splice(customPromptIndex, 1)[0]; + prompts.unshift(customPrompt); + } + + if (!this.currentUser?.can_use_custom_prompts) { + prompts = prompts.filter((p) => p.name !== "custom_prompt"); + } + + prompts.forEach((p) => { + this.prompts[p.id] = p; + }); + + this.promptTypes = prompts.reduce((memo, p) => { + memo[p.name] = p.prompt_type; + return memo; + }, {}); + return prompts; + } + + get reviewButtons() { + return [ + { + icon: "exchange-alt", + label: "discourse_ai.ai_helper.context_menu.view_changes", + action: () => (this.showDiffModal = true), + 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; + } + + @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: { + mode: option.id, + text: this.args.data.selectedText, + custom_prompt: this.customPromptValue, + force_default_locale: true, + }, + }); + + 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.showThumbnailModal = true; + this.thumbnailSuggestions = data.thumbnails; + } else { + this._updateSuggestedByAi(data); + } + } catch (error) { + popupAjaxError(error); + } finally { + this._toggleLoadingState(false); + } + + 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; + } + + @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 new file mode 100644 index 00000000..73dbcb2a --- /dev/null +++ b/assets/javascripts/discourse/components/ai-helper-button-group.gjs @@ -0,0 +1,19 @@ +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 c87280c4..08a5a29f 100644 --- a/assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs +++ b/assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs @@ -1,38 +1,40 @@ import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { Input } from "@ember/component"; import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; 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"; export default class AiHelperCustomPrompt extends Component { - @tracked _customPromptInput; - @action - setupCustomPrompt() { - this._customPromptInput = document.querySelector( - ".ai-custom-prompt__input" - ); - this._customPromptInput.focus(); - } - - @action - sendInput() { + sendInput(event) { + if (event.key !== "Enter") { + return; + } return this.args.submit(this.args.promptArgs); }