FEATURE: AI Helper Context Menu (#148)

This commit is contained in:
Keegan George 2023-08-23 10:35:40 -07:00 committed by GitHub
parent f0e1c72aa7
commit 6df850d473
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 654 additions and 1 deletions

View File

@ -0,0 +1,78 @@
<div {{did-insert this.setupContextMenu}}>
{{#if this.showContextMenu}}
<div class="ai-helper-context-menu">
{{#if (eq this.menuState this.CONTEXT_MENU_STATES.triggers)}}
<ul class="ai-helper-context-menu__trigger">
<li>
<DButton
@icon="magic"
@action={{this.toggleAiHelperOptions}}
@label="discourse_ai.ai_helper.context_menu.trigger"
class="btn-flat"
/>
</li>
</ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
<ul class="ai-helper-context-menu__options">
{{#each this.helperOptions as |option|}}
<li data-name={{option.name}} data-value={{option.value}}>
<DButton
@class="btn-flat"
@translatedLabel={{option.name}}
@action={{this.updateSelected}}
@actionParam={{option.value}}
/>
</li>
{{/each}}
</ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.suggestions)}}
<ul class="ai-helper-context-menu__suggestions">
{{#each this.generatedTitleSuggestions as |suggestion index|}}
<li data-name={{suggestion}} data-value={{index}}>
<DButton
@class="btn-flat"
@translatedLabel={{suggestion}}
@action={{this.updateTopicTitle}}
@actionParam={{suggestion}}
/>
</li>
{{/each}}
</ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
<ul class="ai-helper-context-menu__loading">
<li>
<div class="dot-falling"></div>
<span>
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
</span>
</li>
</ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.resets)}}
<ul class="ai-helper-context-menu__resets">
<li>
<DButton
@icon="undo"
@label="discourse_ai.ai_helper.context_menu.undo"
@action={{this.undoAIAction}}
class="btn-flat undo"
/>
</li>
<li>
<DButton
@icon="discourse-sparkles"
@label="discourse_ai.ai_helper.context_menu.regen"
@action={{this.updateSelected}}
@actionParam={{this.lastUsedOption}}
class="btn-flat"
/>
</li>
</ul>
{{/if}}
</div>
{{/if}}
</div>

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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:

View File

@ -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

View File

@ -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