FEATURE: AI Helper Context Menu (#148)
This commit is contained in:
parent
f0e1c72aa7
commit
6df850d473
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,3 +29,149 @@
|
||||||
.topic-above-suggested-outlet.related-topics {
|
.topic-above-suggested-outlet.related-topics {
|
||||||
margin: 4.5em 0 1em;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,12 @@ en:
|
||||||
title: "Suggest changes using AI"
|
title: "Suggest changes using AI"
|
||||||
description: "Choose one of the options below, and the AI will suggest you a new version of the text."
|
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."
|
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:
|
reviewables:
|
||||||
model_used: "Model used:"
|
model_used: "Model used:"
|
||||||
accuracy: "Accuracy:"
|
accuracy: "Accuracy:"
|
||||||
|
@ -35,7 +41,6 @@ en:
|
||||||
5-turbo: "GPT-3.5"
|
5-turbo: "GPT-3.5"
|
||||||
claude-2: "Claude 2"
|
claude-2: "Claude 2"
|
||||||
|
|
||||||
|
|
||||||
review:
|
review:
|
||||||
types:
|
types:
|
||||||
reviewable_ai_post:
|
reviewable_ai_post:
|
||||||
|
|
|
@ -12,6 +12,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:composer) { PageObjects::Components::Composer.new }
|
let(:composer) { PageObjects::Components::Composer.new }
|
||||||
|
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
|
||||||
let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new }
|
let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new }
|
||||||
|
|
||||||
context "when using the translation mode" do
|
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)
|
expect(find("#reply-title").value).to eq(expected_title)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue