REFACTOR: AI Composer Helper Menu (#715)
This commit is contained in:
parent
7b4c099673
commit
1254d7c7d0
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-composer-helper-menu" {{this.documentListeners}}>
|
||||||
|
{{#if (eq this.aiComposerHelper.menuState this.MENU_STATES.triggers)}}
|
||||||
|
<ul class="ai-composer-helper-menu__triggers">
|
||||||
|
<li>
|
||||||
|
<DButton
|
||||||
|
@icon="discourse-sparkles"
|
||||||
|
@label="discourse_ai.ai_helper.context_menu.trigger"
|
||||||
|
@action={{this.toggleAiHelperOptions}}
|
||||||
|
class="btn-flat"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.options)}}
|
||||||
|
<AiHelperOptionsList
|
||||||
|
@options={{this.helperOptions}}
|
||||||
|
@customPromptValue={{this.customPromptValue}}
|
||||||
|
@performAction={{this.updateSelected}}
|
||||||
|
/>
|
||||||
|
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.loading)}}
|
||||||
|
<AiHelperLoading @cancel={{this.cancelAiAction}} />
|
||||||
|
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.review)}}
|
||||||
|
<AiHelperButtonGroup
|
||||||
|
@buttons={{this.reviewButtons}}
|
||||||
|
class="ai-composer-helper-menu__review"
|
||||||
|
/>
|
||||||
|
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.resets)}}
|
||||||
|
<AiHelperButtonGroup
|
||||||
|
@buttons={{this.resetButtons}}
|
||||||
|
class="ai-composer-helper-menu__resets"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.showDiffModal}}
|
||||||
|
<ModalDiffModal
|
||||||
|
@confirm={{fn
|
||||||
|
(mut this.aiComposerHelper.menuState)
|
||||||
|
this.MENU_STATES.resets
|
||||||
|
}}
|
||||||
|
@diff={{this.diff}}
|
||||||
|
@oldValue={{this.initialValue}}
|
||||||
|
@newValue={{this.newSelectedText}}
|
||||||
|
@revert={{this.undoAiAction}}
|
||||||
|
@closeModal={{fn (mut this.showDiffModal) false}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.showThumbnailModal}}
|
||||||
|
<ThumbnailSuggestion
|
||||||
|
@thumbnails={{this.thumbnailSuggestions}}
|
||||||
|
@closeModal={{fn (mut this.showThumbnailModal) false}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
|
||||||
|
const AiHelperButtonGroup = <template>
|
||||||
|
<ul class="ai-helper-button-group" ...attributes>
|
||||||
|
{{#each @buttons as |button|}}
|
||||||
|
<li>
|
||||||
|
<DButton
|
||||||
|
@icon={{button.icon}}
|
||||||
|
@label={{button.label}}
|
||||||
|
@action={{button.action}}
|
||||||
|
class={{concatClass "btn-flat" button.classes}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default AiHelperButtonGroup;
|
|
@ -1,38 +1,40 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
|
||||||
import { Input } from "@ember/component";
|
|
||||||
import { fn } from "@ember/helper";
|
import { fn } from "@ember/helper";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
|
||||||
import DButton from "discourse/components/d-button";
|
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 i18n from "discourse-common/helpers/i18n";
|
||||||
import not from "truth-helpers/helpers/not";
|
import not from "truth-helpers/helpers/not";
|
||||||
|
|
||||||
export default class AiHelperCustomPrompt extends Component {
|
export default class AiHelperCustomPrompt extends Component {
|
||||||
@tracked _customPromptInput;
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
setupCustomPrompt() {
|
sendInput(event) {
|
||||||
this._customPromptInput = document.querySelector(
|
if (event.key !== "Enter") {
|
||||||
".ai-custom-prompt__input"
|
return;
|
||||||
);
|
}
|
||||||
this._customPromptInput.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
sendInput() {
|
|
||||||
return this.args.submit(this.args.promptArgs);
|
return this.args.submit(this.args.promptArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ai-custom-prompt" {{didInsert this.setupCustomPrompt}}>
|
<div class="ai-custom-prompt">
|
||||||
<Input
|
|
||||||
@value={{@value}}
|
<input
|
||||||
|
{{on "input" (withEventValue (fn (mut @value)))}}
|
||||||
|
{{on "keydown" this.sendInput}}
|
||||||
|
value={{@value}}
|
||||||
placeholder={{i18n
|
placeholder={{i18n
|
||||||
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
|
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
|
||||||
}}
|
}}
|
||||||
class="ai-custom-prompt__input"
|
class="ai-custom-prompt__input"
|
||||||
@enter={{this.sendInput}}
|
type="text"
|
||||||
|
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
|
||||||
|
because built in autofocus doesn't work consistently when component is
|
||||||
|
invoked twice separetly without a page refresh.
|
||||||
|
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
|
||||||
|
--}}
|
||||||
|
{{autoFocus}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DButton
|
<DButton
|
||||||
|
|
|
@ -0,0 +1,204 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import { modifier } from "ember-modifier";
|
||||||
|
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
||||||
|
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||||
|
import { afterRender, bind, debounce } from "discourse-common/utils/decorators";
|
||||||
|
import AiComposerHelperMenu from "../../components/ai-composer-helper-menu";
|
||||||
|
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||||
|
import virtualElementFromCaretCoords from "../../lib/virtual-element-from-caret-coords";
|
||||||
|
|
||||||
|
export default class AiComposerHelper extends Component {
|
||||||
|
static shouldRender(outletArgs, helper) {
|
||||||
|
return showComposerAIHelper(outletArgs, helper, "context_menu");
|
||||||
|
}
|
||||||
|
|
||||||
|
@service menu;
|
||||||
|
@service aiComposerHelper;
|
||||||
|
@tracked caretCoords;
|
||||||
|
@tracked menuPlacement = "bottom-start";
|
||||||
|
@tracked menuOffset = [9, 21];
|
||||||
|
@tracked selectedText = "";
|
||||||
|
@tracked isSelecting = false;
|
||||||
|
@tracked menuElement = null;
|
||||||
|
@tracked menuInstance = null;
|
||||||
|
@tracked dEditorInput;
|
||||||
|
@tracked selectionRange = { x: 0, y: 0 };
|
||||||
|
minSelectionChars = 3;
|
||||||
|
|
||||||
|
documentListeners = modifier(() => {
|
||||||
|
document.addEventListener("mousedown", this.onMouseDown, { passive: true });
|
||||||
|
document.addEventListener("mouseup", this.onMouseUp, { passive: true });
|
||||||
|
document.addEventListener("selectionchange", this.onSelectionChanged);
|
||||||
|
|
||||||
|
this.dEditorInput = document.querySelector(".d-editor-input");
|
||||||
|
|
||||||
|
if (this.dEditorInput) {
|
||||||
|
this.dEditorInput.addEventListener("scroll", this.updatePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", this.onMouseDown);
|
||||||
|
document.removeEventListener("mouseup", this.onMouseUp);
|
||||||
|
document.removeEventListener("selectionchange", this.onSelectionChanged);
|
||||||
|
|
||||||
|
if (this.dEditorInput) {
|
||||||
|
this.dEditorInput.removeEventListener("scroll", this.updatePosition);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
this.menuInstance?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onSelectionChanged() {
|
||||||
|
if (
|
||||||
|
this.aiComposerHelper.menuState !==
|
||||||
|
this.aiComposerHelper.MENU_STATES.triggers
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSelecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.activeElement !== this.dEditorInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSelect = Boolean(
|
||||||
|
window.getSelection() &&
|
||||||
|
document.activeElement &&
|
||||||
|
document.activeElement.value
|
||||||
|
);
|
||||||
|
|
||||||
|
this.selectedText = canSelect
|
||||||
|
? document.activeElement.value.substring(
|
||||||
|
document.activeElement.selectionStart,
|
||||||
|
document.activeElement.selectionEnd
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
this.selectionRange = canSelect
|
||||||
|
? {
|
||||||
|
x: document.activeElement.selectionStart,
|
||||||
|
y: document.activeElement.selectionEnd,
|
||||||
|
}
|
||||||
|
: { x: 0, y: 0 };
|
||||||
|
|
||||||
|
if (this.selectedText?.length === 0) {
|
||||||
|
this.menuInstance?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedText?.length < this.minSelectionChars) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@debounce(INPUT_DELAY)
|
||||||
|
selectionChanged() {
|
||||||
|
this.positionMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onMouseDown() {
|
||||||
|
this.isSelecting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onMouseUp() {
|
||||||
|
this.isSelecting = false;
|
||||||
|
this.onSelectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
updatePosition() {
|
||||||
|
if (!this.menuInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.positionMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@afterRender
|
||||||
|
async positionMenu() {
|
||||||
|
this.caretCoords = getCaretPosition(this.dEditorInput, {
|
||||||
|
pos: caretPosition(this.dEditorInput),
|
||||||
|
});
|
||||||
|
const virtualElement = virtualElementFromCaretCoords(
|
||||||
|
this.caretCoords,
|
||||||
|
this.menuOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.handleBoundaries(virtualElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position context menu at based on if interfering with button bar
|
||||||
|
this.menuInstance = await this.menu.show(virtualElement, {
|
||||||
|
identifier: "ai-composer-helper-menu",
|
||||||
|
placement: this.menuPlacement,
|
||||||
|
component: AiComposerHelperMenu,
|
||||||
|
inline: true,
|
||||||
|
modalForMobile: false,
|
||||||
|
data: {
|
||||||
|
selectedText: this.selectedText,
|
||||||
|
dEditorInput: this.dEditorInput,
|
||||||
|
selectionRange: this.selectionRange,
|
||||||
|
},
|
||||||
|
interactive: true,
|
||||||
|
onClose: () => {
|
||||||
|
this.aiComposerHelper.menuState =
|
||||||
|
this.aiComposerHelper.MENU_STATES.triggers;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.menuElement = this.menuInstance.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBoundaries(virtualElement) {
|
||||||
|
const textAreaWrapper = document
|
||||||
|
.querySelector(".d-editor-textarea-wrapper")
|
||||||
|
.getBoundingClientRect();
|
||||||
|
const buttonBar = document
|
||||||
|
.querySelector(".d-editor-button-bar")
|
||||||
|
.getBoundingClientRect();
|
||||||
|
const boundaryElement = {
|
||||||
|
top: buttonBar.bottom,
|
||||||
|
bottom: textAreaWrapper.bottom,
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuHeightBuffer = 35; // rough estimate of menu height since we can't get actual in this context.
|
||||||
|
if (this.caretCoords.y - menuHeightBuffer < boundaryElement.top) {
|
||||||
|
this.menuPlacement = "bottom-start";
|
||||||
|
} else {
|
||||||
|
this.menuPlacement = "top-start";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isScrolledOutOfBounds(boundaryElement, virtualElement)) {
|
||||||
|
this.menuInstance?.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isScrolledOutOfBounds(boundaryElement, virtualElement) {
|
||||||
|
// Hide context menu if it's scrolled out of bounds:
|
||||||
|
if (virtualElement.rect.top < boundaryElement.top) {
|
||||||
|
return true;
|
||||||
|
} else if (virtualElement.rect.bottom > boundaryElement.bottom) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div {{this.documentListeners}}></div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -1,93 +0,0 @@
|
||||||
<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="discourse-sparkles"
|
|
||||||
@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)}}
|
|
||||||
<AiHelperOptionsList
|
|
||||||
@options={{this.helperOptions}}
|
|
||||||
@customPromptValue={{this.customPromptValue}}
|
|
||||||
@performAction={{this.updateSelected}}
|
|
||||||
/>
|
|
||||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
|
|
||||||
<AiHelperLoading @cancel={{this.cancelAIAction}} />
|
|
||||||
|
|
||||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.review)}}
|
|
||||||
<ul class="ai-helper-context-menu__review">
|
|
||||||
<li>
|
|
||||||
<DButton
|
|
||||||
@icon="exchange-alt"
|
|
||||||
@label="discourse_ai.ai_helper.context_menu.view_changes"
|
|
||||||
@action={{this.viewChanges}}
|
|
||||||
class="btn-flat view-changes"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<DButton
|
|
||||||
@icon="undo"
|
|
||||||
@label="discourse_ai.ai_helper.context_menu.revert"
|
|
||||||
@action={{this.undoAIAction}}
|
|
||||||
class="btn-flat revert"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<DButton
|
|
||||||
@icon="check"
|
|
||||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
|
||||||
@action={{this.confirmChanges}}
|
|
||||||
class="btn-flat confirm"
|
|
||||||
/>
|
|
||||||
</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={{fn this.updateSelected this.lastUsedOption}}
|
|
||||||
class="btn-flat"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{#if this.showDiffModal}}
|
|
||||||
<Modal::DiffModal
|
|
||||||
@confirm={{this.confirmChanges}}
|
|
||||||
@diff={{this.diff}}
|
|
||||||
@oldValue={{this.initialValue}}
|
|
||||||
@newValue={{this.newSelectedText}}
|
|
||||||
@revert={{this.undoAIAction}}
|
|
||||||
@closeModal={{fn (mut this.showDiffModal) false}}
|
|
||||||
/>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if this.showThumbnailModal}}
|
|
||||||
<Modal::ThumbnailSuggestions
|
|
||||||
@thumbnails={{this.thumbnailSuggestions}}
|
|
||||||
@closeModal={{fn (mut this.showThumbnailModal) false}}
|
|
||||||
/>
|
|
||||||
{{/if}}
|
|
|
@ -1,444 +0,0 @@
|
||||||
import Component from "@glimmer/component";
|
|
||||||
import { tracked } from "@glimmer/tracking";
|
|
||||||
import { action } from "@ember/object";
|
|
||||||
import { inject as service } from "@ember/service";
|
|
||||||
import { createPopper } from "@popperjs/core";
|
|
||||||
import { ajax } from "discourse/lib/ajax";
|
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
||||||
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
|
||||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
|
||||||
import { afterRender, bind, debounce } from "discourse-common/utils/decorators";
|
|
||||||
import I18n from "discourse-i18n";
|
|
||||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
|
||||||
|
|
||||||
export default class AiHelperContextMenu extends Component {
|
|
||||||
static shouldRender(outletArgs, helper) {
|
|
||||||
return showComposerAIHelper(outletArgs, helper, "context_menu");
|
|
||||||
}
|
|
||||||
|
|
||||||
@service currentUser;
|
|
||||||
@service siteSettings;
|
|
||||||
@service modal;
|
|
||||||
@service capabilities;
|
|
||||||
@tracked showContextMenu = false;
|
|
||||||
@tracked caretCoords;
|
|
||||||
@tracked virtualElement;
|
|
||||||
@tracked selectedText = "";
|
|
||||||
@tracked newSelectedText;
|
|
||||||
@tracked loading = false;
|
|
||||||
@tracked lastUsedOption = null;
|
|
||||||
@tracked showDiffModal = false;
|
|
||||||
@tracked showThumbnailModal = false;
|
|
||||||
@tracked diff;
|
|
||||||
@tracked popperPlacement = "top-start";
|
|
||||||
@tracked previousMenuState = null;
|
|
||||||
@tracked customPromptValue = "";
|
|
||||||
@tracked initialValue = "";
|
|
||||||
@tracked thumbnailSuggestions = null;
|
|
||||||
@tracked selectionRange = { x: 0, y: 0 };
|
|
||||||
@tracked lastSelectionRange = null;
|
|
||||||
|
|
||||||
CONTEXT_MENU_STATES = {
|
|
||||||
triggers: "TRIGGERS",
|
|
||||||
options: "OPTIONS",
|
|
||||||
resets: "RESETS",
|
|
||||||
loading: "LOADING",
|
|
||||||
review: "REVIEW",
|
|
||||||
};
|
|
||||||
prompts = [];
|
|
||||||
promptTypes = {};
|
|
||||||
minSelectionChars = 3;
|
|
||||||
|
|
||||||
@tracked _menuState = this.CONTEXT_MENU_STATES.triggers;
|
|
||||||
@tracked _popper;
|
|
||||||
@tracked _dEditorInput;
|
|
||||||
@tracked _customPromptInput;
|
|
||||||
@tracked _contextMenu;
|
|
||||||
@tracked _activeAIRequest = null;
|
|
||||||
|
|
||||||
willDestroy() {
|
|
||||||
super.willDestroy(...arguments);
|
|
||||||
document.removeEventListener("selectionchange", this.selectionChanged);
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
this._popper?.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
get menuState() {
|
|
||||||
return this._menuState;
|
|
||||||
}
|
|
||||||
|
|
||||||
set menuState(newState) {
|
|
||||||
this.previousMenuState = this._menuState;
|
|
||||||
this._menuState = newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
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._showUserCustomPrompts()) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
selectionChanged() {
|
|
||||||
if (document.activeElement !== this._dEditorInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSelect = Boolean(
|
|
||||||
window.getSelection() &&
|
|
||||||
document.activeElement &&
|
|
||||||
document.activeElement.value
|
|
||||||
);
|
|
||||||
|
|
||||||
this.selectedText = canSelect
|
|
||||||
? document.activeElement.value.substring(
|
|
||||||
document.activeElement.selectionStart,
|
|
||||||
document.activeElement.selectionEnd
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
this.selectionRange = canSelect
|
|
||||||
? {
|
|
||||||
x: document.activeElement.selectionStart,
|
|
||||||
y: document.activeElement.selectionEnd,
|
|
||||||
}
|
|
||||||
: { x: 0, y: 0 };
|
|
||||||
|
|
||||||
if (this.selectedText?.length === 0) {
|
|
||||||
this.closeContextMenu();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.selectedText?.length < this.minSelectionChars) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._onSelectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
updatePosition() {
|
|
||||||
if (!this.showContextMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.positionContextMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onKeyDown(event) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
return this.closeContextMenu();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
event.key === "Backspace" &&
|
|
||||||
this.selectedText &&
|
|
||||||
this.menuState === this.CONTEXT_MENU_STATES.triggers
|
|
||||||
) {
|
|
||||||
return this.closeContextMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get canCloseContextMenu() {
|
|
||||||
if (document.activeElement === this._customPromptInput) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.loading && this._activeAIRequest !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.menuState === this.CONTEXT_MENU_STATES.review) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeContextMenu() {
|
|
||||||
if (!this.canCloseContextMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.showContextMenu = false;
|
|
||||||
this.menuState = this.CONTEXT_MENU_STATES.triggers;
|
|
||||||
this.customPromptValue = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateSuggestedByAI(data) {
|
|
||||||
this.newSelectedText = data.suggestions[0];
|
|
||||||
|
|
||||||
if (data.diff) {
|
|
||||||
this.diff = data.diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._insertAt(
|
|
||||||
this.selectionRange.x,
|
|
||||||
this.selectionRange.y,
|
|
||||||
this.newSelectedText
|
|
||||||
);
|
|
||||||
this.menuState = this.CONTEXT_MENU_STATES.review;
|
|
||||||
}
|
|
||||||
|
|
||||||
_insertAt(start, end, text) {
|
|
||||||
this._dEditorInput.setSelectionRange(start, end);
|
|
||||||
this._dEditorInput.focus();
|
|
||||||
document.execCommand("insertText", false, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
_toggleLoadingState(loading) {
|
|
||||||
if (loading) {
|
|
||||||
this._dEditorInput.classList.add("loading");
|
|
||||||
return (this.loading = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._dEditorInput.classList.remove("loading");
|
|
||||||
return (this.loading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
_showUserCustomPrompts() {
|
|
||||||
return this.currentUser?.can_use_custom_prompts;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBoundaries() {
|
|
||||||
const textAreaWrapper = document
|
|
||||||
.querySelector(".d-editor-textarea-wrapper")
|
|
||||||
.getBoundingClientRect();
|
|
||||||
const buttonBar = document
|
|
||||||
.querySelector(".d-editor-button-bar")
|
|
||||||
.getBoundingClientRect();
|
|
||||||
|
|
||||||
const boundaryElement = {
|
|
||||||
top: buttonBar.bottom,
|
|
||||||
bottom: textAreaWrapper.bottom,
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextMenuRect = this._contextMenu.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Hide context menu if it's scrolled out of bounds:
|
|
||||||
if (contextMenuRect.top < boundaryElement.top) {
|
|
||||||
this._contextMenu.classList.add("out-of-bounds");
|
|
||||||
} else if (contextMenuRect.bottom > boundaryElement.bottom) {
|
|
||||||
this._contextMenu.classList.add("out-of-bounds");
|
|
||||||
} else {
|
|
||||||
this._contextMenu.classList.remove("out-of-bounds");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position context menu at based on if interfering with button bar
|
|
||||||
if (this.caretCoords.y - contextMenuRect.height < boundaryElement.top) {
|
|
||||||
this.popperPlacement = "bottom-start";
|
|
||||||
} else {
|
|
||||||
this.popperPlacement = "top-start";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@afterRender
|
|
||||||
positionContextMenu() {
|
|
||||||
this._contextMenu = document.querySelector(".ai-helper-context-menu");
|
|
||||||
|
|
||||||
if (!this._dEditorInput || !this._contextMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.caretCoords = getCaretPosition(this._dEditorInput, {
|
|
||||||
pos: caretPosition(this._dEditorInput),
|
|
||||||
});
|
|
||||||
|
|
||||||
// prevent overflow of context menu outside of editor
|
|
||||||
this.handleBoundaries();
|
|
||||||
|
|
||||||
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: this.popperPlacement,
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "offset",
|
|
||||||
options: {
|
|
||||||
offset: [10, 0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
setupContextMenu() {
|
|
||||||
document.addEventListener("selectionchange", this.selectionChanged);
|
|
||||||
document.addEventListener("keydown", this.onKeyDown);
|
|
||||||
|
|
||||||
this._dEditorInput = document.querySelector(".d-editor-input");
|
|
||||||
|
|
||||||
if (this._dEditorInput) {
|
|
||||||
this._dEditorInput.addEventListener("scroll", this.updatePosition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
setupCustomPrompt() {
|
|
||||||
this._customPromptInput = document.querySelector(
|
|
||||||
".ai-custom-prompt__input"
|
|
||||||
);
|
|
||||||
this._customPromptInput.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
toggleAiHelperOptions() {
|
|
||||||
this.menuState = this.CONTEXT_MENU_STATES.options;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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.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.menuState = this.CONTEXT_MENU_STATES.resets;
|
|
||||||
this.closeContextMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
async updateSelected(option) {
|
|
||||||
this._toggleLoadingState(true);
|
|
||||||
this.lastUsedOption = option;
|
|
||||||
this.menuState = this.CONTEXT_MENU_STATES.loading;
|
|
||||||
this.initialValue = this.selectedText;
|
|
||||||
this.lastSelectionRange = this.selectionRange;
|
|
||||||
|
|
||||||
this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", {
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
mode: option.id,
|
|
||||||
text: this.selectedText,
|
|
||||||
custom_prompt: this.customPromptValue,
|
|
||||||
force_default_locale: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this._activeAIRequest
|
|
||||||
.then((data) => {
|
|
||||||
// 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.closeContextMenu();
|
|
||||||
this.showThumbnailModal = true;
|
|
||||||
this.thumbnailSuggestions = data.thumbnails;
|
|
||||||
} else {
|
|
||||||
this._updateSuggestedByAI(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(popupAjaxError)
|
|
||||||
.finally(() => {
|
|
||||||
this._toggleLoadingState(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this._activeAIRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
viewChanges() {
|
|
||||||
this.showDiffModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
confirmChanges() {
|
|
||||||
this.menuState = this.CONTEXT_MENU_STATES.resets;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
cancelAIAction() {
|
|
||||||
if (this._activeAIRequest) {
|
|
||||||
this._activeAIRequest.abort();
|
|
||||||
this._activeAIRequest = null;
|
|
||||||
this._toggleLoadingState(false);
|
|
||||||
this.closeContextMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
togglePreviousMenu() {
|
|
||||||
this.menuState = this.previousMenuState;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
class VirtualElementFromCaretCoords {
|
||||||
|
constructor(caretCoords, offset = [0, 0]) {
|
||||||
|
this.caretCoords = caretCoords;
|
||||||
|
this.offset = offset;
|
||||||
|
this.updateRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRect() {
|
||||||
|
const [xOffset, yOffset] = this.offset;
|
||||||
|
this.rect = {
|
||||||
|
top: this.caretCoords.y + yOffset,
|
||||||
|
right: this.caretCoords.x,
|
||||||
|
bottom: this.caretCoords.y + yOffset,
|
||||||
|
left: this.caretCoords.x + xOffset,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: this.caretCoords.x,
|
||||||
|
y: this.caretCoords.y,
|
||||||
|
toJSON() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return this.rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientRects() {
|
||||||
|
return [this.rect];
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientWidth() {
|
||||||
|
return this.rect.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientHeight() {
|
||||||
|
return this.rect.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function virtualElementFromCaretCoords(caretCoords, offset) {
|
||||||
|
return new VirtualElementFromCaretCoords(caretCoords, offset);
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import Service from "@ember/service";
|
||||||
|
|
||||||
|
export default class AiComposerHelper extends Service {
|
||||||
|
@tracked menuState = this.MENU_STATES.triggers;
|
||||||
|
|
||||||
|
MENU_STATES = {
|
||||||
|
triggers: "TRIGGERS",
|
||||||
|
options: "OPTIONS",
|
||||||
|
resets: "RESETS",
|
||||||
|
loading: "LOADING",
|
||||||
|
review: "REVIEW",
|
||||||
|
};
|
||||||
|
}
|
|
@ -31,7 +31,7 @@
|
||||||
margin: 4.5em 0 1em;
|
margin: 4.5em 0 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-helper-context-menu {
|
.ai-composer-helper-menu {
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
|
@ -40,16 +40,16 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
&.out-of-bounds {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-helper-button-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-flow: row wrap;
|
||||||
&__resets {
|
&__resets {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -606,3 +606,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fk-d-menu[data-identifier="ai-composer-helper-menu"] {
|
||||||
|
z-index: z("composer", "dropdown");
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:input) { "The rain in spain stays mainly in the Plane." }
|
let(:input) { "The rain in spain stays mainly in the Plane." }
|
||||||
|
|
||||||
let(:composer) { PageObjects::Components::Composer.new }
|
let(:composer) { PageObjects::Components::Composer.new }
|
||||||
let(:ai_helper_context_menu) { PageObjects::Components::AIHelperContextMenu.new }
|
let(:ai_helper_context_menu) { PageObjects::Components::AiComposerHelperMenu.new }
|
||||||
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
||||||
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
||||||
fab!(:category)
|
fab!(:category)
|
||||||
|
@ -239,21 +239,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip,
|
input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip,
|
||||||
)
|
)
|
||||||
diff_modal.confirm_changes
|
diff_modal.confirm_changes
|
||||||
expect(ai_helper_context_menu).to be_showing_resets
|
expect(ai_helper_context_menu).to have_no_context_menu
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should not close the context menu when in review state" do
|
|
||||||
trigger_context_menu(spanish_input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
find(".d-editor-preview").click
|
|
||||||
expect(ai_helper_context_menu).to have_context_menu
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
module PageObjects
|
module PageObjects
|
||||||
module Components
|
module Components
|
||||||
class AIHelperContextMenu < PageObjects::Components::Base
|
class AiComposerHelperMenu < PageObjects::Components::Base
|
||||||
COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
|
COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
|
||||||
CONTEXT_MENU_SELECTOR = ".ai-helper-context-menu"
|
CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu"
|
||||||
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__trigger"
|
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__triggers"
|
||||||
OPTIONS_STATE_SELECTOR = ".ai-helper-options"
|
OPTIONS_STATE_SELECTOR = ".ai-helper-options"
|
||||||
LOADING_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__loading"
|
LOADING_STATE_SELECTOR = ".ai-helper-loading"
|
||||||
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
|
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
|
||||||
REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review"
|
REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review"
|
||||||
CUSTOM_PROMPT_SELECTOR = "#{CONTEXT_MENU_SELECTOR} .ai-custom-prompt"
|
CUSTOM_PROMPT_SELECTOR = "#{CONTEXT_MENU_SELECTOR} .ai-custom-prompt"
|
||||||
|
@ -41,7 +41,7 @@ module PageObjects
|
||||||
end
|
end
|
||||||
|
|
||||||
def press_undo_keys
|
def press_undo_keys
|
||||||
find(COMPOSER_EDITOR_SELECTOR).send_keys([:control, "z"])
|
find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"])
|
||||||
end
|
end
|
||||||
|
|
||||||
def press_escape_key
|
def press_escape_key
|
Loading…
Reference in New Issue