From 6df850d4735f82b2ccb8963fda05913f7046d416 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Wed, 23 Aug 2023 10:35:40 -0700 Subject: [PATCH] FEATURE: AI Helper Context Menu (#148) --- .../after-d-editor/ai-helper-context-menu.hbs | 78 ++++++ .../after-d-editor/ai-helper-context-menu.js | 233 ++++++++++++++++++ .../modules/ai-helper/common/ai-helper.scss | 146 +++++++++++ config/locales/client.en.yml | 7 +- .../ai_helper/ai_composer_helper_spec.rb | 133 ++++++++++ .../components/ai_helper_context_menu.rb | 58 +++++ 6 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.hbs create mode 100644 assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js create mode 100644 spec/system/page_objects/components/ai_helper_context_menu.rb diff --git a/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.hbs b/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.hbs new file mode 100644 index 00000000..44af073c --- /dev/null +++ b/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.hbs @@ -0,0 +1,78 @@ +
+ {{#if this.showContextMenu}} +
+ {{#if (eq this.menuState this.CONTEXT_MENU_STATES.triggers)}} +
    +
  • + +
  • +
+ + {{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}} +
    + {{#each this.helperOptions as |option|}} +
  • + +
  • + {{/each}} +
+ + {{else if (eq this.menuState this.CONTEXT_MENU_STATES.suggestions)}} +
    + {{#each this.generatedTitleSuggestions as |suggestion index|}} +
  • + +
  • + {{/each}} +
+ + {{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}} +
    +
  • +
    + + {{i18n "discourse_ai.ai_helper.context_menu.loading"}} + +
  • +
+ + {{else if (eq this.menuState this.CONTEXT_MENU_STATES.resets)}} +
    +
  • + +
  • +
  • + +
  • +
+ + {{/if}} +
+ {{/if}} +
\ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js b/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js new file mode 100644 index 00000000..731feb2d --- /dev/null +++ b/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js @@ -0,0 +1,233 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { afterRender, bind, debounce } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { createPopper } from "@popperjs/core"; +import { caretPosition, getCaretPosition } from "discourse/lib/utilities"; +import discourseLater from "discourse-common/lib/later"; +import { inject as service } from "@ember/service"; + +export default class AiHelperContextMenu extends Component { + static shouldRender(outletArgs, helper) { + return ( + helper.siteSettings.discourse_ai_enabled && + helper.siteSettings.composer_ai_helper_enabled + ); + } + + @service siteSettings; + @tracked helperOptions = []; + @tracked showContextMenu = false; + @tracked menuState = this.CONTEXT_MENU_STATES.triggers; + @tracked caretCoords; + @tracked virtualElement; + @tracked selectedText = ""; + @tracked loading = false; + @tracked oldEditorValue; + @tracked generatedTitleSuggestions = []; + @tracked lastUsedOption = null; + + CONTEXT_MENU_STATES = { + triggers: "TRIGGERS", + options: "OPTIONS", + resets: "RESETS", + loading: "LOADING", + suggesions: "SUGGESTIONS", + }; + prompts = []; + promptTypes = {}; + + @tracked _popper; + @tracked _dEditorInput; + @tracked _contextMenu; + + willDestroy() { + super.willDestroy(...arguments); + document.removeEventListener("selectionchange", this.selectionChanged); + this._popper?.destroy(); + } + + async loadPrompts() { + let prompts = await ajax("/discourse-ai/ai-helper/prompts"); + + prompts.map((p) => { + this.prompts[p.id] = p; + }); + + this.promptTypes = prompts.reduce((memo, p) => { + memo[p.name] = p.prompt_type; + return memo; + }, {}); + + this.helperOptions = prompts.map((p) => { + return { + name: p.translated_name, + value: p.id, + }; + }); + } + + @bind + selectionChanged(event) { + if (!event.target.activeElement.classList.contains("d-editor-input")) { + return; + } + + if (window.getSelection().toString().length === 0) { + if (this.loading) { + // prevent accidentally closing context menu while results loading + return; + } + + this.closeContextMenu(); + return; + } + + this.selectedText = event.target.getSelection().toString(); + this._onSelectionChanged(); + } + + @bind + updatePosition() { + if (!this.showContextMenu) { + return; + } + + this.positionContextMenu(); + } + + @debounce(INPUT_DELAY) + _onSelectionChanged() { + this.positionContextMenu(); + this.showContextMenu = true; + } + + generateGetBoundingClientRect(width = 0, height = 0, x = 0, y = 0) { + return () => ({ + width, + height, + top: y, + right: x, + bottom: y, + left: x, + }); + } + + closeContextMenu() { + this.showContextMenu = false; + this.menuState = this.CONTEXT_MENU_STATES.triggers; + } + + _updateSuggestedByAI(data) { + const composer = this.args.outletArgs.composer; + this.oldEditorValue = this._dEditorInput.value; + const newValue = this.oldEditorValue.replace( + this.selectedText, + data.suggestions[0] + ); + composer.set("reply", newValue); + this.menuState = this.CONTEXT_MENU_STATES.resets; + } + + @afterRender + positionContextMenu() { + this._contextMenu = document.querySelector(".ai-helper-context-menu"); + this.caretCoords = getCaretPosition(this._dEditorInput, { + pos: caretPosition(this._dEditorInput), + }); + + this.virtualElement = { + getBoundingClientRect: this.generateGetBoundingClientRect( + this._contextMenu.clientWidth, + this._contextMenu.clientHeight, + this.caretCoords.x, + this.caretCoords.y + ), + }; + + this._popper = createPopper(this.virtualElement, this._contextMenu, { + placement: "top-start", + modifiers: [ + { + name: "offset", + options: { + offset: [10, 0], + }, + }, + ], + }); + } + + @action + setupContextMenu() { + document.addEventListener("selectionchange", this.selectionChanged); + + this._dEditorInput = document.querySelector(".d-editor-input"); + + if (this._dEditorInput) { + this._dEditorInput.addEventListener("scroll", this.updatePosition); + } + } + + @action + toggleAiHelperOptions() { + // Fetch prompts only if it hasn't been fetched yet + if (this.helperOptions.length === 0) { + this.loadPrompts(); + } + this.menuState = this.CONTEXT_MENU_STATES.options; + } + + @action + undoAIAction() { + const composer = this.args.outletArgs.composer; + composer.set("reply", this.oldEditorValue); + this.closeContextMenu(); + } + + @action + async updateSelected(option) { + this.loading = true; + this.lastUsedOption = option; + this._dEditorInput.classList.add("loading"); + this.menuState = this.CONTEXT_MENU_STATES.loading; + + return ajax("/discourse-ai/ai-helper/suggest", { + method: "POST", + data: { mode: option, text: this.selectedText }, + }) + .then((data) => { + if (this.prompts[option].name === "generate_titles") { + this.menuState = this.CONTEXT_MENU_STATES.suggestions; + this.generatedTitleSuggestions = data.suggestions; + } else { + this._updateSuggestedByAI(data); + } + }) + .catch(popupAjaxError) + .finally(() => { + this.loading = false; + this._dEditorInput.classList.remove("loading"); + + // Make reset options disappear by closing the context menu after 5 seconds + if (this.menuState === this.CONTEXT_MENU_STATES.resets) { + discourseLater(() => { + this.closeContextMenu(); + }, 5000); + } + }); + } + + @action + updateTopicTitle(title) { + const composer = this.args.outletArgs?.composer; + + if (composer) { + composer.set("title", title); + this.closeContextMenu(); + } + } +} diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index 463143c0..537ed387 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -29,3 +29,149 @@ .topic-above-suggested-outlet.related-topics { margin: 4.5em 0 1em; } + +.ai-helper-context-menu { + background: var(--secondary); + box-shadow: var(--shadow-dropdown); + padding: 0.25rem; + max-width: 15rem; + border: 1px solid var(--primary-low); + list-style: none; + z-index: 999; + + ul { + margin: 0; + list-style: none; + } + + ul:not(.ai-helper-context-menu__loading) li { + transition: background-color 0.25s ease; + &:hover { + background: var(--primary-low); + } + } + + .d-button-label { + color: var(--primary-very-high); + } + + &__options { + padding: 0.25rem; + } + + &__loading { + .dot-falling { + margin-inline: 1rem; + margin-left: 1.5rem; + } + + li { + display: flex; + padding: 0.5rem; + gap: 1rem; + justify-content: flex-start; + align-items: center; + } + } + + &__resets { + display: flex; + align-items: center; + flex-flow: row wrap; + } +} + +.d-editor-input.loading { + animation: loading-text 1.5s infinite linear; +} + +@keyframes loading-text { + 0% { + color: var(--primary); + } + 50% { + color: var(--tertiary); + } + 100% { + color: var(--primary); + } +} + +// AI Typing indicator (taken from: https://github.com/nzbin/three-dots) +.dot-falling { + position: relative; + left: -9999px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: var(--tertiary); + color: var(--tertiary); + box-shadow: 9999px 0 0 0 var(--tertiary); + animation: dot-falling 1s infinite linear; + animation-delay: 0.1s; +} +.dot-falling::before, +.dot-falling::after { + content: ""; + display: inline-block; + position: absolute; + top: 0; +} +.dot-falling::before { + width: 10px; + height: 10px; + border-radius: 5px; + background-color: var(--tertiary); + color: var(--tertiary); + animation: dot-falling-before 1s infinite linear; + animation-delay: 0s; +} +.dot-falling::after { + width: 10px; + height: 10px; + border-radius: 5px; + background-color: var(--tertiary); + color: var(--tertiary); + animation: dot-falling-after 1s infinite linear; + animation-delay: 0.2s; +} + +@keyframes dot-falling { + 0% { + box-shadow: 9999px -15px 0 0 rgba(152, 128, 255, 0); + } + 25%, + 50%, + 75% { + box-shadow: 9999px 0 0 0 var(--tertiary); + } + 100% { + box-shadow: 9999px 15px 0 0 rgba(152, 128, 255, 0); + } +} +@keyframes dot-falling-before { + 0% { + box-shadow: 9984px -15px 0 0 rgba(152, 128, 255, 0); + } + 25%, + 50%, + 75% { + box-shadow: 9984px 0 0 0 var(--tertiary); + } + 100% { + box-shadow: 9984px 15px 0 0 rgba(152, 128, 255, 0); + } +} +@keyframes dot-falling-after { + 0% { + box-shadow: 10014px -15px 0 0 rgba(152, 128, 255, 0); + } + 25%, + 50%, + 75% { + box-shadow: 10014px 0 0 0 var(--tertiary); + } + 100% { + box-shadow: 10014px 15px 0 0 rgba(152, 128, 255, 0); + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2e642e60..d3fb684c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -16,6 +16,12 @@ en: title: "Suggest changes using AI" description: "Choose one of the options below, and the AI will suggest you a new version of the text." selection_hint: "Hint: You can also select a portion of the text before opening the helper to rewrite only that." + context_menu: + trigger: "AI" + undo: "Undo" + loading: "AI is generating" + cancel: "Cancel" + regen: "Try Again" reviewables: model_used: "Model used:" accuracy: "Accuracy:" @@ -35,7 +41,6 @@ en: 5-turbo: "GPT-3.5" claude-2: "Claude 2" - review: types: reviewable_ai_post: diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb index 12df0db4..8086b04f 100644 --- a/spec/system/ai_helper/ai_composer_helper_spec.rb +++ b/spec/system/ai_helper/ai_composer_helper_spec.rb @@ -12,6 +12,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end let(:composer) { PageObjects::Components::Composer.new } + let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new } let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new } context "when using the translation mode" do @@ -83,4 +84,136 @@ RSpec.describe "AI Composer helper", type: :system, js: true do expect(find("#reply-title").value).to eq(expected_title) end end + + 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(OpenAiCompletionsInferenceStubs.translated_response) + expect(ai_helper_context_menu).to have_context_menu + end + + it "shows context menu in 'trigger' state when first showing" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.translated_response) + 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(OpenAiCompletionsInferenceStubs.translated_response) + ai_helper_context_menu.click_ai_button + expect(ai_helper_context_menu).to be_showing_options + end + + context "when using translation mode" do + let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE } + before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } + + it "replaces the composed message with AI generated content" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.spanish_text) + ai_helper_context_menu.click_ai_button + ai_helper_context_menu.select_helper_model( + OpenAiCompletionsInferenceStubs.text_mode_to_id(mode), + ) + + wait_for do + composer.composer_input.value == OpenAiCompletionsInferenceStubs.translated_response.strip + end + + expect(composer.composer_input.value).to eq( + OpenAiCompletionsInferenceStubs.translated_response.strip, + ) + end + + it "shows reset options after results are complete" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.spanish_text) + ai_helper_context_menu.click_ai_button + ai_helper_context_menu.select_helper_model( + OpenAiCompletionsInferenceStubs.text_mode_to_id(mode), + ) + + wait_for do + composer.composer_input.value == OpenAiCompletionsInferenceStubs.translated_response.strip + end + + expect(ai_helper_context_menu).to be_showing_resets + end + + it "hides reset options after 5 seconds" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.spanish_text) + ai_helper_context_menu.click_ai_button + ai_helper_context_menu.select_helper_model( + OpenAiCompletionsInferenceStubs.text_mode_to_id(mode), + ) + + wait_for do + composer.composer_input.value == OpenAiCompletionsInferenceStubs.translated_response.strip + end + + expect(ai_helper_context_menu).to be_showing_resets + sleep 5 + expect(ai_helper_context_menu).to be_not_showing_resets + end + + it "reverts results when Undo button is clicked" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.spanish_text) + ai_helper_context_menu.click_ai_button + ai_helper_context_menu.select_helper_model( + OpenAiCompletionsInferenceStubs.text_mode_to_id(mode), + ) + + wait_for do + composer.composer_input.value == OpenAiCompletionsInferenceStubs.translated_response.strip + end + + ai_helper_context_menu.click_undo_button + expect(composer.composer_input.value).to eq(OpenAiCompletionsInferenceStubs.spanish_text) + end + end + + context "when using the proofreading mode" do + let(:mode) { OpenAiCompletionsInferenceStubs::PROOFREAD } + before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } + + it "replaces the composed message with AI generated content" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.translated_response) + ai_helper_context_menu.click_ai_button + ai_helper_context_menu.select_helper_model( + OpenAiCompletionsInferenceStubs.text_mode_to_id(mode), + ) + + wait_for do + composer.composer_input.value == OpenAiCompletionsInferenceStubs.proofread_response.strip + end + + expect(composer.composer_input.value).to eq( + OpenAiCompletionsInferenceStubs.proofread_response.strip, + ) + end + end + + context "when selecting an AI generated title" do + let(:mode) { OpenAiCompletionsInferenceStubs::GENERATE_TITLES } + before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } + + it "replaces the topic title" do + trigger_context_menu(OpenAiCompletionsInferenceStubs.translated_response) + ai_helper_context_menu.click_ai_button + ai_helper_context_menu.select_helper_model( + OpenAiCompletionsInferenceStubs.text_mode_to_id(mode), + ) + expect(ai_helper_context_menu).to be_showing_suggestions + + ai_helper_context_menu.select_title_suggestion(2) + expected_title = "The Quiet Piece that Moves Literature: A Gaucho's Story" + + wait_for { find("#reply-title").value == expected_title } + expect(find("#reply-title").value).to eq(expected_title) + end + end + end end diff --git a/spec/system/page_objects/components/ai_helper_context_menu.rb b/spec/system/page_objects/components/ai_helper_context_menu.rb new file mode 100644 index 00000000..525df55f --- /dev/null +++ b/spec/system/page_objects/components/ai_helper_context_menu.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class AIHelperContextMenu < PageObjects::Components::Base + CONTEXT_MENU_SELECTOR = ".ai-helper-context-menu" + TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__trigger" + OPTIONS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__options" + SUGGESTIONS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__suggestions" + LOADING_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__loading" + RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets" + + def click_ai_button + find("#{TRIGGER_STATE_SELECTOR} .btn").click + end + + def select_helper_model(mode) + find("#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .btn").click + end + + def select_title_suggestion(option_number) + find("#{SUGGESTIONS_STATE_SELECTOR} li[data-value=\"#{option_number}\"] .btn").click + end + + def click_undo_button + find("#{RESETS_STATE_SELECTOR} .undo").click + end + + def has_context_menu? + page.has_css?(CONTEXT_MENU_SELECTOR) + end + + def showing_triggers? + page.has_css?(TRIGGER_STATE_SELECTOR) + end + + def showing_options? + page.has_css?(OPTIONS_STATE_SELECTOR) + end + + def showing_suggestions? + page.has_css?(SUGGESTIONS_STATE_SELECTOR) + end + + def showing_loading? + page.has_css?(LOADING_STATE_SELECTOR) + end + + def showing_resets? + page.has_css?(RESETS_STATE_SELECTOR) + end + + def not_showing_resets? + page.has_no_css?(RESETS_STATE_SELECTOR) + end + end + end +end