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}}
+
\ 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