DEV: Move composer AI helper to toolbar (#796)

Previously we had moved the AI helper from the options menu to a selection menu that appears when selecting text in the composer. This had the benefit of making the AI helper a more discoverable feature. Now that some time has passed and the AI helper is more recognized, we will be moving it back to the composer toolbar.

This is better because:
- It consistent with other behavior and ways of accessing tools in the composer
- It has an improved mobile experience
- It reduces unnecessary code and keeps things easier to migrate when we have composer V2.
- It allows for easily triggering AI helper for all content by clicking the button instead of having to select everything.
This commit is contained in:
Keegan George 2024-09-13 11:59:30 -07:00 committed by GitHub
parent 5b9add0ac8
commit 9cd14b0003
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 388 additions and 1028 deletions

View File

@ -2,15 +2,7 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; 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 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 AiHelperOptionsList from "../components/ai-helper-options-list";
import ModalDiffModal from "../components/modal/diff-modal"; import ModalDiffModal from "../components/modal/diff-modal";
import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions"; import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
@ -18,30 +10,14 @@ import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
export default class AiComposerHelperMenu extends Component { export default class AiComposerHelperMenu extends Component {
@service modal; @service modal;
@service siteSettings; @service siteSettings;
@service aiComposerHelper;
@service currentUser; @service currentUser;
@service capabilities; @service site;
@tracked newSelectedText; @tracked newSelectedText;
@tracked diff; @tracked diff;
@tracked initialValue = "";
@tracked customPromptValue = ""; @tracked customPromptValue = "";
@tracked loading = false;
@tracked lastUsedOption = null;
@tracked thumbnailSuggestions = null;
@tracked showThumbnailModal = false;
@tracked lastSelectionRange = null;
MENU_STATES = this.aiComposerHelper.MENU_STATES;
prompts = []; prompts = [];
promptTypes = {}; promptTypes = {};
documentListeners = modifier(() => {
document.addEventListener("keydown", this.onKeyDown, { passive: true });
return () => {
document.removeEventListener("keydown", this.onKeyDown);
};
});
get helperOptions() { get helperOptions() {
let prompts = this.currentUser?.ai_helper_prompts; let prompts = this.currentUser?.ai_helper_prompts;
@ -94,260 +70,51 @@ export default class AiComposerHelperMenu extends Component {
return prompts; return prompts;
} }
get reviewButtons() {
return [
{
icon: "exchange-alt",
label: "discourse_ai.ai_helper.context_menu.view_changes",
action: () =>
this.modal.show(ModalDiffModal, {
model: {
diff: this.diff,
oldValue: this.initialValue,
newValue: this.newSelectedText,
revert: this.undoAiAction,
confirm: () => this.updateMenuState(this.MENU_STATES.resets),
},
}),
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;
}
get isExpanded() {
if (this.aiComposerHelper.menuState === this.MENU_STATES.triggers) {
return "";
}
return "is-expanded";
}
@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 @action
toggleAiHelperOptions() { suggestChanges(option) {
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") { if (option.name === "illustrate_post") {
this._toggleLoadingState(false);
this.closeMenu();
this.thumbnailSuggestions = data.thumbnails;
this.modal.show(ThumbnailSuggestion, { this.modal.show(ThumbnailSuggestion, {
model: { model: {
mode: option.id,
selectedText: this.args.data.selectedText,
thumbnails: this.thumbnailSuggestions, thumbnails: this.thumbnailSuggestions,
}, },
}); });
} else { return this.args.close();
this._updateSuggestedByAi(data);
}
} catch (error) {
popupAjaxError(error);
} finally {
this._toggleLoadingState(false);
} }
return this._activeAiRequest; this.modal.show(ModalDiffModal, {
} model: {
mode: option.id,
@action selectedText: this.args.data.selectedText,
cancelAiAction() { revert: this.undoAiAction,
if (this._activeAiRequest) { toolbarEvent: this.args.data.toolbarEvent,
this._activeAiRequest.abort(); customPromptValue: this.customPromptValue,
this._activeAiRequest = null; },
this._toggleLoadingState(false); });
this.closeMenu(); return this.args.close();
}
}
@action
updateMenuState(newState) {
this.aiComposerHelper.menuState = newState;
} }
@action @action
closeMenu() { closeMenu() {
if (!this.canCloseMenu) {
return;
}
this.customPromptValue = ""; this.customPromptValue = "";
this.updateMenuState(this.MENU_STATES.triggers);
this.args.close(); 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> <template>
<div <div class="ai-composer-helper-menu">
class="ai-composer-helper-menu {{this.isExpanded}}" {{#if this.site.mobileView}}
{{this.documentListeners}} <div class="ai-composer-helper-menu__selected-text">
> {{@data.selectedText}}
{{#if (eq this.aiComposerHelper.menuState this.MENU_STATES.triggers)}} </div>
<ul class="ai-composer-helper-menu__triggers"> {{/if}}
<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 <AiHelperOptionsList
@options={{this.helperOptions}} @options={{this.helperOptions}}
@customPromptValue={{this.customPromptValue}} @customPromptValue={{this.customPromptValue}}
@performAction={{this.updateSelected}} @performAction={{this.suggestChanges}}
@shortcutVisible={{true}}
/> />
{{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> </div>
</template> </template>
} }

View File

@ -1,19 +0,0 @@
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;

View File

@ -4,7 +4,6 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value"; 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";
@ -29,12 +28,7 @@ export default class AiHelperCustomPrompt extends Component {
}} }}
class="ai-custom-prompt__input" class="ai-custom-prompt__input"
type="text" type="text"
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus" 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

View File

@ -1,9 +1,19 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper"; import { fn } from "@ember/helper";
import { service } from "@ember/service";
import { and } from "truth-helpers";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import eq from "truth-helpers/helpers/eq"; import eq from "truth-helpers/helpers/eq";
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt"; import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
const AiHelperOptionsList = <template> export default class AiHelperOptionsList extends Component {
@service site;
get showShortcut() {
return this.site.desktopView && this.args.shortcutVisible;
}
<template>
<ul class="ai-helper-options"> <ul class="ai-helper-options">
{{#each @options as |option|}} {{#each @options as |option|}}
{{#if (eq option.name "custom_prompt")}} {{#if (eq option.name "custom_prompt")}}
@ -21,11 +31,14 @@ const AiHelperOptionsList = <template>
data-name={{option.name}} data-name={{option.name}}
data-value={{option.id}} data-value={{option.id}}
class="ai-helper-options__button" class="ai-helper-options__button"
/> >
{{#if (and (eq option.name "proofread") this.showShortcut)}}
<kbd class="shortcut">⌘⌥p</kbd>
{{/if}}
</DButton>
</li> </li>
{{/if}} {{/if}}
{{/each}} {{/each}}
</ul> </ul>
</template>; </template>
}
export default AiHelperOptionsList;

View File

@ -295,6 +295,7 @@ export default class AiPostHelperMenu extends Component {
@options={{this.helperOptions}} @options={{this.helperOptions}}
@customPromptValue={{this.customPromptValue}} @customPromptValue={{this.customPromptValue}}
@performAction={{this.performAiSuggestion}} @performAction={{this.performAiSuggestion}}
@shortcutVisible={{false}}
/> />
{{else if (eq this.menuState this.MENU_STATES.loading)}} {{else if (eq this.menuState this.MENU_STATES.loading)}}
<AiHelperLoading @cancel={{this.cancelAiAction}} /> <AiHelperLoading @cancel={{this.cancelAiAction}} />

View File

@ -1,9 +1,9 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import CookText from "discourse/components/cook-text"; import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal"; import DModal from "discourse/components/d-modal";
@ -15,30 +15,24 @@ export default class ModalDiffModal extends Component {
@service currentUser; @service currentUser;
@tracked loading = false; @tracked loading = false;
@tracked diff; @tracked diff;
suggestion = ""; @tracked suggestion = "";
PROOFREAD_ID = -303;
constructor() { constructor() {
super(...arguments); super(...arguments);
this.diff = this.args.model.diff; this.suggestChanges();
next(() => {
if (this.args.model.toolbarEvent) {
this.loadDiff();
}
});
} }
async loadDiff() { @action
async suggestChanges() {
this.loading = true; this.loading = true;
try { try {
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", { const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
method: "POST", method: "POST",
data: { data: {
mode: this.PROOFREAD_ID, mode: this.args.model.mode,
text: this.selectedText, text: this.args.model.selectedText,
custom_prompt: this.args.model.customPromptValue,
force_default_locale: true, force_default_locale: true,
}, },
}); });
@ -52,37 +46,18 @@ export default class ModalDiffModal extends Component {
} }
} }
get selectedText() {
const selected = this.args.model.toolbarEvent.selected;
if (selected.value === "") {
return selected.pre + selected.post;
}
return selected.value;
}
@action @action
triggerConfirmChanges() { triggerConfirmChanges() {
this.args.closeModal(); this.args.closeModal();
if (this.args.model.confirm) {
this.args.model.confirm();
}
if (this.args.model.toolbarEvent && this.suggestion) { if (this.suggestion) {
this.args.model.toolbarEvent.replaceText( this.args.model.toolbarEvent.replaceText(
this.selectedText, this.args.model.selectedText,
this.suggestion this.suggestion
); );
} }
} }
@action
triggerRevertChanges() {
this.args.model.revert();
this.args.closeModal();
}
<template> <template>
<DModal <DModal
class="composer-ai-helper-modal" class="composer-ai-helper-modal"
@ -90,6 +65,7 @@ export default class ModalDiffModal extends Component {
@closeModal={{@closeModal}} @closeModal={{@closeModal}}
> >
<:body> <:body>
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.loading}} {{#if this.loading}}
<div class="composer-ai-helper-modal__loading"> <div class="composer-ai-helper-modal__loading">
<CookText @rawText={{this.selectedText}} /> <CookText @rawText={{this.selectedText}} />
@ -99,14 +75,16 @@ export default class ModalDiffModal extends Component {
{{htmlSafe this.diff}} {{htmlSafe this.diff}}
{{else}} {{else}}
<div class="composer-ai-helper-modal__old-value"> <div class="composer-ai-helper-modal__old-value">
{{@model.oldValue}} {{@model.selectedText}}
</div> </div>
<div class="composer-ai-helper-modal__new-value"> <div class="composer-ai-helper-modal__new-value">
{{@model.newValue}} {{this.suggestion}}
</div> </div>
{{/if}} {{/if}}
{{/if}} {{/if}}
</ConditionalLoadingSpinner>
</:body> </:body>
<:footer> <:footer>
@ -122,13 +100,17 @@ export default class ModalDiffModal extends Component {
@action={{this.triggerConfirmChanges}} @action={{this.triggerConfirmChanges}}
@label="discourse_ai.ai_helper.context_menu.confirm" @label="discourse_ai.ai_helper.context_menu.confirm"
/> />
{{#if @model.revert}}
<DButton <DButton
class="btn-flat revert" class="btn-flat discard"
@action={{this.triggerRevertChanges}} @action={{@closeModal}}
@label="discourse_ai.ai_helper.context_menu.revert" @label="discourse_ai.ai_helper.context_menu.discard"
/>
<DButton
class="regenerate"
@icon="arrows-rotate"
@action={{this.suggestChanges}}
@label="discourse_ai.ai_helper.context_menu.regen"
/> />
{{/if}}
{{/if}} {{/if}}
</:footer> </:footer>
</DModal> </DModal>

View File

@ -1,19 +1,50 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal"; import DModal from "discourse/components/d-modal";
import DModalCancel from "discourse/components/d-modal-cancel"; import DModalCancel from "discourse/components/d-modal-cancel";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
import ThumbnailSuggestionItem from "../thumbnail-suggestion-item"; import ThumbnailSuggestionItem from "../thumbnail-suggestion-item";
export default class ThumbnailSuggestions extends Component { export default class ThumbnailSuggestions extends Component {
@tracked loading = false;
@tracked selectedImages = []; @tracked selectedImages = [];
@tracked thumbnails = null;
constructor() {
super(...arguments);
this.findThumbnails();
}
get isDisabled() { get isDisabled() {
return this.selectedImages.length === 0; return this.selectedImages.length === 0;
} }
async findThumbnails() {
this.loading = true;
try {
const thumbnails = await ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: this.args.model.mode,
text: this.args.model.selectedText,
force_default_locale: true,
},
});
this.thumbnails = thumbnails.thumbnails;
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}
@action @action
addSelection(selection) { addSelection(selection) {
const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`; const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`;
@ -52,8 +83,9 @@ export default class ThumbnailSuggestions extends Component {
@closeModal={{@closeModal}} @closeModal={{@closeModal}}
> >
<:body> <:body>
<ConditionalLoadingSpinner @condition={{this.loading}}>
<div class="ai-thumbnail-suggestions"> <div class="ai-thumbnail-suggestions">
{{#each @model.thumbnails as |thumbnail|}} {{#each this.thumbnails as |thumbnail|}}
<ThumbnailSuggestionItem <ThumbnailSuggestionItem
@thumbnail={{thumbnail}} @thumbnail={{thumbnail}}
@addSelection={{this.addSelection}} @addSelection={{this.addSelection}}
@ -61,6 +93,7 @@ export default class ThumbnailSuggestions extends Component {
/> />
{{/each}} {{/each}}
</div> </div>
</ConditionalLoadingSpinner>
</:body> </:body>
<:footer> <:footer>

View File

@ -1,11 +1,16 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { showComposerAIHelper } from "../../lib/show-ai-helper"; import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiCategorySuggestion extends Component { export default class AiCategorySuggestion extends Component {
static shouldRender(outletArgs, helper) { static shouldRender(outletArgs, helper) {
return showComposerAIHelper(outletArgs, helper, "suggestions"); return showComposerAiHelper(
outletArgs?.composer,
helper.siteSettings,
helper.currentUser,
"suggestions"
);
} }
@service siteSettings; @service siteSettings;

View File

@ -1,11 +1,16 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { showComposerAIHelper } from "../../lib/show-ai-helper"; import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiTagSuggestion extends Component { export default class AiTagSuggestion extends Component {
static shouldRender(outletArgs, helper) { static shouldRender(outletArgs, helper) {
return showComposerAIHelper(outletArgs, helper, "suggestions"); return showComposerAiHelper(
outletArgs?.composer,
helper.siteSettings,
helper.currentUser,
"suggestions"
);
} }
@service siteSettings; @service siteSettings;

View File

@ -1,10 +1,15 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { showComposerAIHelper } from "../../lib/show-ai-helper"; import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiTitleSuggestion extends Component { export default class AiTitleSuggestion extends Component {
static shouldRender(outletArgs, helper) { static shouldRender(outletArgs, helper) {
return showComposerAIHelper(outletArgs, helper, "suggestions"); return showComposerAiHelper(
outletArgs?.composer,
helper.siteSettings,
helper.currentUser,
"suggestions"
);
} }
<template> <template>

View File

@ -1,221 +0,0 @@
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 site;
@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);
window.visualViewport.addEventListener("resize", this.onResize, {
passive: true,
});
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);
window.visualViewport.removeEventListener("resize", this.onResize);
if (this.dEditorInput) {
this.dEditorInput.removeEventListener("scroll", this.updatePosition);
}
};
});
willDestroy() {
super.willDestroy(...arguments);
this.menuInstance?.close();
}
@bind
onResize() {
if (!this.site.mobileView) {
return;
}
document.documentElement.style.setProperty(
"--mobile-virtual-screen-height",
`${window.visualViewport.height}px`
);
}
@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>
}

View File

@ -1,11 +1,16 @@
export function showComposerAIHelper(outletArgs, helper, featureType) { export function showComposerAiHelper(
const enableHelper = _helperEnabled(helper.siteSettings); composerModel,
const enableAssistant = helper.currentUser.can_use_assistant; siteSettings,
const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm; currentUser,
featureType
) {
const enableHelper = _helperEnabled(siteSettings);
const enableAssistant = currentUser.can_use_assistant;
const canShowInPM = siteSettings.ai_helper_allowed_in_pm;
const enableFeature = const enableFeature =
helper.siteSettings.ai_helper_enabled_features.includes(featureType); siteSettings.ai_helper_enabled_features.includes(featureType);
if (outletArgs?.composer?.privateMessage) { if (composerModel?.privateMessage) {
return enableHelper && enableAssistant && canShowInPM && enableFeature; return enableHelper && enableAssistant && canShowInPM && enableFeature;
} }

View File

@ -1,14 +0,0 @@
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",
};
}

View File

@ -1,42 +1,87 @@
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
import AiComposerHelperMenu from "../discourse/components/ai-composer-helper-menu";
import ModalDiffModal from "../discourse/components/modal/diff-modal"; import ModalDiffModal from "../discourse/components/modal/diff-modal";
import { showComposerAiHelper } from "../discourse/lib/show-ai-helper";
function initializeProofread(api) { function initializeAiHelperTrigger(api) {
api.addComposerToolbarPopupMenuOption({ const showErrorToast = () => {
action: (toolbarEvent) => {
const modal = api.container.lookup("service:modal");
const composer = api.container.lookup("service:composer");
const toasts = api.container.lookup("service:toasts"); const toasts = api.container.lookup("service:toasts");
if (composer.model.reply?.length === 0) { return toasts.error({
toasts.error({
duration: 3000, duration: 3000,
data: { data: {
message: i18n("discourse_ai.ai_helper.proofread.no_content_error"), message: i18n("discourse_ai.ai_helper.no_content_error"),
}, },
}); });
return; };
api.onToolbarCreate((toolbar) => {
const currentUser = api.getCurrentUser();
const modal = api.container.lookup("service:modal");
const selectedText = (toolbarEvent) => {
const composerContent = toolbarEvent.getText();
const selected = toolbarEvent.selected.value;
if (selected && selected.length > 0) {
return selected;
} }
if (composerContent && composerContent.length > 0) {
return composerContent;
}
};
toolbar.addButton({
id: "ai-helper-trigger",
group: "extras",
icon: "discourse-sparkles",
title: "discourse_ai.ai_helper.context_menu.trigger",
preventFocus: true,
shortcut: "ALT+P",
shortcutAction: (toolbarEvent) => {
if (toolbarEvent.getText().length === 0) {
return showErrorToast();
}
const mode = currentUser?.ai_helper_prompts.find(
(p) => p.name === "proofread"
).id;
modal.show(ModalDiffModal, { modal.show(ModalDiffModal, {
model: { model: {
mode,
selectedText: selectedText(toolbarEvent),
toolbarEvent, toolbarEvent,
}, },
}); });
}, },
icon: "spell-check", condition: () =>
label: "discourse_ai.ai_helper.context_menu.proofread_prompt", showComposerAiHelper(
shortcut: "ALT+P", api.container.lookup("service:composer").model,
condition: () => { api.container.lookup("service:site-settings"),
const siteSettings = api.container.lookup("service:site-settings"); currentUser,
const currentUser = api.getCurrentUser(); "context_menu"
),
sendAction: (event) => {
if (toolbar.context.value.length === 0) {
return showErrorToast();
}
return ( const menu = api.container.lookup("service:menu");
siteSettings.ai_helper_enabled && currentUser?.can_use_assistant_in_post menu.show(document.querySelector(".ai-helper-trigger"), {
); identifier: "ai-composer-helper-menu",
component: AiComposerHelperMenu,
modalForMobile: true,
interactive: true,
data: {
toolbarEvent: event,
selectedText: selectedText(event),
}, },
}); });
},
});
});
} }
export default { export default {
@ -44,7 +89,7 @@ export default {
initialize() { initialize() {
withPluginApi("1.1.0", (api) => { withPluginApi("1.1.0", (api) => {
initializeProofread(api); initializeAiHelperTrigger(api);
}); });
}, },
}; };

View File

@ -37,6 +37,12 @@
background-color: var(--success-low); background-color: var(--success-low);
color: var(--success); color: var(--success);
} }
.d-modal__footer {
.regenerate {
margin-left: auto;
}
}
} }
.topic-above-suggested-outlet.related-topics { .topic-above-suggested-outlet.related-topics {
@ -44,11 +50,8 @@
} }
.ai-composer-helper-menu { .ai-composer-helper-menu {
background: var(--secondary);
box-shadow: var(--shadow-card);
padding: 0.25rem; padding: 0.25rem;
max-width: 25rem; max-width: 25rem;
border: 1px solid var(--primary-low);
list-style: none; list-style: none;
z-index: 999; z-index: 999;
@ -58,23 +61,6 @@
} }
} }
.ai-helper-button-group {
display: flex;
align-items: center;
flex-flow: row wrap;
&__resets {
display: flex;
align-items: center;
flex-flow: row wrap;
}
&__review {
display: flex;
align-items: center;
flex-flow: row wrap;
}
}
.ai-custom-prompt { .ai-custom-prompt {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
@ -569,6 +555,19 @@
margin: 0; margin: 0;
list-style: none; list-style: none;
li {
display: flex;
align-items: center;
.shortcut {
border: none;
background: none;
font-size: var(--font-down-1);
color: var(--primary-low-mid);
margin-left: auto;
}
}
&__button { &__button {
justify-content: left; justify-content: left;
text-align: left; text-align: left;

View File

@ -15,18 +15,6 @@
overflow: hidden; overflow: hidden;
} }
.ai-post-helper-menu {
&__selected-text {
margin: 0.75em 1rem;
padding: 0.5em;
border-radius: var(--d-border-radius);
border: 1px solid var(--primary-low);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.125);
overflow: auto;
overscroll-behavior: contain;
}
}
.ai-helper-options { .ai-helper-options {
padding: 0; padding: 0;
} }
@ -40,4 +28,17 @@
justify-content: center; justify-content: center;
} }
} }
.ai-post-helper-menu,
.ai-composer-helper-menu {
&__selected-text {
margin: 0.75em 1rem;
padding: 0.5em;
border-radius: var(--d-border-radius);
border: 1px solid var(--primary-low);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.125);
overflow: auto;
overscroll-behavior: contain;
}
}
} }

View File

@ -1,87 +0,0 @@
:root {
// Overridden in ai-composer-helper.gjs to adjust for virtual keyboard
--mobile-virtual-screen-height: 100svh;
}
.ai-composer-helper-menu:not(.is-expanded) {
--composer-helper-menu-padding: 1rem;
--composer-helper-menu-trigger-size: 3em;
--composer-helper-menu-height: calc(1rem + 3em);
position: fixed;
list-style: none;
inset: auto;
transform: none;
max-width: unset;
z-index: z("fullscreen");
top: calc(
var(--mobile-virtual-screen-height) - var(--composer-helper-menu-height) -
env(keyboard-inset-height)
);
right: 0;
padding-right: 1rem;
padding-bottom: var(--composer-helper-menu-padding);
margin: 0px;
width: 100vw;
text-align: right;
border: none;
background: linear-gradient(
to bottom,
transparent 0,
rgba(0, 0, 0, 0.3) 100%
);
box-shadow: none;
&.out-of-bounds {
visibility: visible;
pointer-events: initial;
}
button {
background: var(--secondary);
border: 1px solid var(--primary-low);
border-radius: 100%;
box-shadow: 0 0 10px 2px
dark-light-choose(
rgba(var(--primary-rgb), 0.1),
rgba(var(--secondary-rgb), 0.1)
);
width: var(--composer-helper-menu-trigger-size);
height: var(--composer-helper-menu-trigger-size);
animation-duration: 0.15s;
animation-timing-function: cubic-bezier(0, 0.7, 0.9, 1.2);
animation-fill-mode: backwards;
animation-name: slide-in;
&:hover {
.discourse-no-touch & {
background: var(--secondary);
}
}
.d-button-label {
display: none;
}
.d-icon {
margin: 0;
color: var(--tertiary-high);
}
}
}
.ios-safari-composer-hacks .ai-composer-helper-menu:not(.is-expanded) {
top: calc(
var(--mobile-virtual-screen-height) - var(--composer-helper-menu-height)
);
}
@keyframes slide-in {
0% {
opacity: 0;
transform: translateY(calc(100% + 1rem));
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -293,22 +293,18 @@ en:
suggest: "Suggest with AI" suggest: "Suggest with AI"
missing_content: "Please enter some content to generate suggestions." missing_content: "Please enter some content to generate suggestions."
context_menu: context_menu:
back: "Back" trigger: "Ask AI"
trigger: "AI" loading: "AI is generating..."
undo: "Undo"
loading: "AI is generating"
cancel: "Cancel" cancel: "Cancel"
regen: "Try Again" regen: "Try Again"
view_changes: "View Changes"
confirm: "Confirm" confirm: "Confirm"
revert: "Revert" discard: "Discard"
changes: "Suggested Edits" changes: "Suggested Edits"
custom_prompt: custom_prompt:
title: "Custom Prompt" title: "Custom Prompt"
placeholder: "Enter a custom prompt..." placeholder: "Enter a custom prompt..."
submit: "Send Prompt" submit: "Send Prompt"
translate_prompt: "Translate to %{language}" translate_prompt: "Translate to %{language}"
proofread_prompt: "Proofread Text"
post_options_menu: post_options_menu:
trigger: "Ask AI" trigger: "Ask AI"
title: "Ask AI" title: "Ask AI"
@ -336,8 +332,7 @@ en:
prompt: "This post contains non-captioned images. Would you like to enable automatic AI captions on image uploads? (This can be changed in your preferences later)" prompt: "This post contains non-captioned images. Would you like to enable automatic AI captions on image uploads? (This can be changed in your preferences later)"
confirm: "Enable" confirm: "Enable"
cancel: "Don't ask again" cancel: "Don't ask again"
proofread: no_content_error: "Please add some content to perform AI actions on."
no_content_error: "Unable to proofread text, please add some content to proofread."
reviewables: reviewables:
model_used: "Model used:" model_used: "Model used:"

View File

@ -17,7 +17,6 @@ register_asset "stylesheets/common/streaming.scss"
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss" register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper.scss", :mobile
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper-fk-modals.scss", :mobile register_asset "stylesheets/modules/ai-helper/mobile/ai-helper-fk-modals.scss", :mobile
register_asset "stylesheets/modules/summarization/mobile/ai-summary.scss", :mobile register_asset "stylesheets/modules/summarization/mobile/ai-summary.scss", :mobile

View File

@ -12,11 +12,12 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
end end
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::AiComposerHelperMenu.new } let(:ai_helper_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 }
let(:toasts) { PageObjects::Components::Toasts.new }
fab!(:category) fab!(:category)
fab!(:category_2) { Fabricate(:category) } fab!(:category_2) { Fabricate(:category) }
fab!(:video) { Fabricate(:tag) } fab!(:video) { Fabricate(:tag) }
@ -25,51 +26,28 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
fab!(:feedback) { Fabricate(:tag) } fab!(:feedback) { Fabricate(:tag) }
fab!(:review) { Fabricate(:tag) } fab!(:review) { Fabricate(:tag) }
def trigger_context_menu(content) def trigger_composer_helper(content)
visit("/latest") visit("/latest")
page.find("#create-topic").click page.find("#create-topic").click
composer.fill_content(content) composer.fill_content(content)
page.execute_script("document.querySelector('.d-editor-input')?.select();") composer.click_toolbar_button("ai-helper-trigger")
end end
context "when triggering AI with context menu in composer" do context "when triggering composer AI helper" do
it "shows the context menu when selecting a passage of text in the composer" do it "shows the context menu when clicking the AI button in the composer toolbar" do
trigger_context_menu(input) trigger_composer_helper(input)
expect(ai_helper_context_menu).to have_context_menu expect(ai_helper_menu).to have_context_menu
end end
it "does not show the context menu when selecting insufficient text" do it "shows a toast error when clicking the AI button without content" do
visit("/latest") trigger_composer_helper("")
page.find("#create-topic").click expect(ai_helper_menu).to have_no_context_menu
composer.fill_content(input) expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.no_content_error"))
page.execute_script(
"const input = document.querySelector('.d-editor-input'); input.setSelectionRange(0, 2);",
)
expect(ai_helper_context_menu).to have_no_context_menu
end end
it "shows context menu in 'trigger' state when first showing" do it "shows prompt options in menu when AI button is clicked" do
trigger_context_menu(input) trigger_composer_helper(input)
expect(ai_helper_context_menu).to be_showing_triggers expect(ai_helper_menu).to be_showing_options
end
it "shows prompt options in context menu when AI button is clicked" do
trigger_context_menu(input)
ai_helper_context_menu.click_ai_button
expect(ai_helper_context_menu).to be_showing_options
end
it "closes the context menu when clicking outside" do
trigger_context_menu(input)
find(".d-editor-preview").click
expect(ai_helper_context_menu).to have_no_context_menu
end
it "closes the context menu when selected text is deleted" do
trigger_context_menu(input)
expect(ai_helper_context_menu).to have_context_menu
page.send_keys(:backspace)
expect(ai_helper_context_menu).to have_no_context_menu
end end
context "when using custom prompt" do context "when using custom prompt" do
@ -79,41 +57,28 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." } let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." }
it "shows custom prompt option" do it "shows custom prompt option" do
trigger_context_menu(input) trigger_composer_helper(input)
ai_helper_context_menu.click_ai_button expect(ai_helper_menu).to have_custom_prompt
expect(ai_helper_context_menu).to have_custom_prompt
end end
it "enables the custom prompt button when input is filled" do it "enables the custom prompt button when input is filled" do
trigger_context_menu(input) trigger_composer_helper(input)
ai_helper_context_menu.click_ai_button expect(ai_helper_menu).to have_custom_prompt_button_disabled
expect(ai_helper_context_menu).to have_custom_prompt_button_disabled ai_helper_menu.fill_custom_prompt(custom_prompt_input)
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input) expect(ai_helper_menu).to have_custom_prompt_button_enabled
expect(ai_helper_context_menu).to have_custom_prompt_button_enabled
end end
it "replaces the composed message with AI generated content" do it "replaces the composed message with AI generated content" do
trigger_context_menu(input) trigger_composer_helper(input)
ai_helper_context_menu.click_ai_button ai_helper_menu.fill_custom_prompt(custom_prompt_input)
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
DiscourseAi::Completions::Llm.with_prepared_responses([custom_prompt_response]) do DiscourseAi::Completions::Llm.with_prepared_responses([custom_prompt_response]) do
ai_helper_context_menu.click_custom_prompt_button ai_helper_menu.click_custom_prompt_button
diff_modal.confirm_changes
wait_for { composer.composer_input.value == custom_prompt_response } wait_for { composer.composer_input.value == custom_prompt_response }
expect(composer.composer_input.value).to eq(custom_prompt_response) expect(composer.composer_input.value).to eq(custom_prompt_response)
end end
end end
it "should not close the context menu if backspace is pressed" do
trigger_context_menu(input)
ai_helper_context_menu.click_ai_button
expect(ai_helper_context_menu).to have_context_menu
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
page.find(".ai-custom-prompt__input").send_keys(:backspace)
expect(ai_helper_context_menu).to have_context_menu
end
end end
context "when not a member of custom prompt group" do context "when not a member of custom prompt group" do
@ -121,9 +86,8 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s } before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s }
it "does not show custom prompt option" do it "does not show custom prompt option" do
trigger_context_menu(input) trigger_composer_helper(input)
ai_helper_context_menu.click_ai_button expect(ai_helper_menu).to have_no_custom_prompt
expect(ai_helper_context_menu).to have_no_custom_prompt
end end
end end
@ -133,129 +97,51 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." } let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." }
it "replaces the composed message with AI generated content" do it "replaces the composed message with AI generated content" do
trigger_context_menu(spanish_input) trigger_composer_helper(spanish_input)
ai_helper_context_menu.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
ai_helper_context_menu.select_helper_model(mode) ai_helper_menu.select_helper_model(mode)
diff_modal.confirm_changes
wait_for { composer.composer_input.value == input } wait_for { composer.composer_input.value == input }
expect(composer.composer_input.value).to eq(input) expect(composer.composer_input.value).to eq(input)
end end
end end
it "shows reset options after results are complete" 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 }
ai_helper_context_menu.click_confirm_button
expect(ai_helper_context_menu).to be_showing_resets
end
end
it "reverts results when Undo button is clicked" 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 }
ai_helper_context_menu.click_confirm_button
ai_helper_context_menu.click_undo_button
expect(composer.composer_input.value).to eq(spanish_input)
end
end
it "reverts results when revert button is clicked" 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 }
ai_helper_context_menu.click_revert_button
expect(composer.composer_input.value).to eq(spanish_input)
end
end
it "reverts results when Ctrl/Cmd + Z is pressed on the keyboard" do it "reverts results when Ctrl/Cmd + Z is pressed on the keyboard" do
trigger_context_menu(spanish_input) trigger_composer_helper(spanish_input)
ai_helper_context_menu.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
ai_helper_context_menu.select_helper_model(mode) ai_helper_menu.select_helper_model(mode)
diff_modal.confirm_changes
wait_for { composer.composer_input.value == input } wait_for { composer.composer_input.value == input }
ai_helper_menu.press_undo_keys
ai_helper_context_menu.press_undo_keys
expect(composer.composer_input.value).to eq(spanish_input) expect(composer.composer_input.value).to eq(spanish_input)
end end
end end
it "confirms the results when confirm button is pressed" do it "shows the changes in a modal" do
trigger_context_menu(spanish_input) trigger_composer_helper(spanish_input)
ai_helper_context_menu.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
ai_helper_context_menu.select_helper_model(mode) ai_helper_menu.select_helper_model(mode)
wait_for { composer.composer_input.value == input }
ai_helper_context_menu.click_confirm_button
expect(composer.composer_input.value).to eq(input)
end
end
it "hides the context menu when pressing Escape on the keyboard" do
trigger_context_menu(spanish_input)
ai_helper_context_menu.click_ai_button
ai_helper_context_menu.press_escape_key
expect(ai_helper_context_menu).to have_no_context_menu
end
it "shows the changes in a modal when view changes button is pressed" 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 }
ai_helper_context_menu.click_view_changes_button
expect(diff_modal).to be_visible expect(diff_modal).to be_visible
expect(diff_modal.old_value).to eq(spanish_input.gsub(/[[:space:]]+/, " ").strip) expect(diff_modal.old_value).to eq(spanish_input.gsub(/[[:space:]]+/, " ").strip)
expect(diff_modal.new_value).to eq( expect(diff_modal.new_value).to eq(
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 have_no_context_menu expect(ai_helper_menu).to have_no_context_menu
end end
end end
it "reverts the changes when revert button is pressed in the modal" do it "does not apply the changes when discard button is pressed in the modal" do
trigger_context_menu(spanish_input) trigger_composer_helper(spanish_input)
ai_helper_context_menu.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
ai_helper_context_menu.select_helper_model(mode) ai_helper_menu.select_helper_model(mode)
wait_for { composer.composer_input.value == input }
ai_helper_context_menu.click_view_changes_button
expect(diff_modal).to be_visible expect(diff_modal).to be_visible
diff_modal.revert_changes diff_modal.discard_changes
expect(ai_helper_context_menu).to have_no_context_menu expect(ai_helper_menu).to have_no_context_menu
expect(composer.composer_input.value).to eq(spanish_input) expect(composer.composer_input.value).to eq(spanish_input)
end end
end end
@ -267,14 +153,12 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." } let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." }
it "replaces the composed message with AI generated content" do it "replaces the composed message with AI generated content" do
trigger_context_menu(input) trigger_composer_helper(input)
ai_helper_context_menu.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do
ai_helper_context_menu.select_helper_model(mode) ai_helper_menu.select_helper_model(mode)
diff_modal.confirm_changes
wait_for { composer.composer_input.value == proofread_text } wait_for { composer.composer_input.value == proofread_text }
expect(composer.composer_input.value).to eq(proofread_text) expect(composer.composer_input.value).to eq(proofread_text)
end end
end end
@ -402,9 +286,11 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:mode) { CompletionPrompt::GENERATE_TITLES } let(:mode) { CompletionPrompt::GENERATE_TITLES }
before { SiteSetting.ai_helper_enabled = false } before { SiteSetting.ai_helper_enabled = false }
it "does not trigger AI context menu" do it "does not show the AI helper button in the composer toolbar" do
trigger_context_menu(input) visit("/latest")
expect(ai_helper_context_menu).to have_no_context_menu page.find("#create-topic").click
composer.fill_content(input)
expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger")
end end
it "does not trigger AI suggestion buttons" do it "does not trigger AI suggestion buttons" do
@ -419,9 +305,11 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
let(:mode) { CompletionPrompt::GENERATE_TITLES } let(:mode) { CompletionPrompt::GENERATE_TITLES }
before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s } before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s }
it "does not trigger AI context menu" do it "does not show the AI helper button in the composer toolbar" do
trigger_context_menu(input) visit("/latest")
expect(ai_helper_context_menu).to have_no_context_menu page.find("#create-topic").click
composer.fill_content(input)
expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger")
end end
it "does not trigger AI suggestion buttons" do it "does not trigger AI suggestion buttons" do
@ -444,12 +332,14 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
end end
end end
context "when context menu feature is disabled" do context "when composer helper feature is disabled" do
before { SiteSetting.ai_helper_enabled_features = "suggestions" } before { SiteSetting.ai_helper_enabled_features = "suggestions" }
it "does not show context menu in the composer" do it "does not show button in the composer toolbar" do
trigger_context_menu(input) visit("/latest")
expect(ai_helper_context_menu).to have_no_context_menu page.find("#create-topic").click
composer.fill_content(input)
expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger")
end end
end end
end end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
include SystemHelpers
RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) } fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
@ -11,19 +13,18 @@ RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
let(:composer) { PageObjects::Components::Composer.new } let(:composer) { PageObjects::Components::Composer.new }
let(:toasts) { PageObjects::Components::Toasts.new } let(:toasts) { PageObjects::Components::Toasts.new }
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
it "proofreads selected text using the composer toolbar" do context "when triggering via keyboard shortcut" do
it "proofreads selected text using" do
visit "/new-topic" visit "/new-topic"
composer.fill_content("hello worldd !") composer.fill_content("hello worldd !")
composer.select_range(6, 12) composer.select_range(6, 12)
DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options") composer.composer_input.send_keys([PLATFORM_KEY_MODIFIER, :alt, "p"])
ai_toolbar.expand diff_modal.confirm_changes
ai_toolbar.select_row_by_name("Proofread Text")
find(".composer-ai-helper-modal .btn-primary.confirm").click
expect(composer.composer_input.value).to eq("hello world !") expect(composer.composer_input.value).to eq("hello world !")
end end
end end
@ -34,11 +35,8 @@ RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
# Simulate AI response # Simulate AI response
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options") composer.composer_input.send_keys([PLATFORM_KEY_MODIFIER, :alt, "p"])
ai_toolbar.expand diff_modal.confirm_changes
ai_toolbar.select_row_by_name("Proofread Text")
find(".composer-ai-helper-modal .btn-primary.confirm").click
expect(composer.composer_input.value).to eq("hello world") expect(composer.composer_input.value).to eq("hello world")
end end
end end
@ -48,10 +46,9 @@ RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
# Simulate AI response # Simulate AI response
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options") composer.composer_input.send_keys([PLATFORM_KEY_MODIFIER, :alt, "p"])
ai_toolbar.expand expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.no_content_error"))
ai_toolbar.select_row_by_name("Proofread Text") end
expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.proofread.no_content_error"))
end end
end end
end end

View File

@ -5,41 +5,18 @@ module PageObjects
class AiComposerHelperMenu < PageObjects::Components::Base class AiComposerHelperMenu < PageObjects::Components::Base
COMPOSER_EDITOR_SELECTOR = ".d-editor-input" COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu" CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu"
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__triggers"
OPTIONS_STATE_SELECTOR = ".ai-helper-options" OPTIONS_STATE_SELECTOR = ".ai-helper-options"
LOADING_STATE_SELECTOR = ".ai-helper-loading" LOADING_STATE_SELECTOR = ".ai-helper-loading"
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
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"
CUSTOM_PROMPT_INPUT_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__input" CUSTOM_PROMPT_INPUT_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__input"
CUSTOM_PROMPT_BUTTON_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__submit" CUSTOM_PROMPT_BUTTON_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__submit"
def click_ai_button
find("#{TRIGGER_STATE_SELECTOR} .btn").click
end
def select_helper_model(mode) def select_helper_model(mode)
find( find(
"#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .ai-helper-options__button", "#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .ai-helper-options__button",
).click ).click
end end
def click_undo_button
find("#{RESETS_STATE_SELECTOR} .undo").click
end
def click_revert_button
find("#{REVIEW_STATE_SELECTOR} .revert").click
end
def click_view_changes_button
find("#{REVIEW_STATE_SELECTOR} .view-changes").click
end
def click_confirm_button
find("#{REVIEW_STATE_SELECTOR} .confirm").click
end
def press_undo_keys def press_undo_keys
find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"]) find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"])
end end
@ -65,10 +42,6 @@ module PageObjects
page.has_no_css?(CONTEXT_MENU_SELECTOR) page.has_no_css?(CONTEXT_MENU_SELECTOR)
end end
def showing_triggers?
page.has_css?(TRIGGER_STATE_SELECTOR)
end
def showing_options? def showing_options?
page.has_css?(OPTIONS_STATE_SELECTOR) page.has_css?(OPTIONS_STATE_SELECTOR)
end end
@ -77,14 +50,6 @@ module PageObjects
page.has_css?(LOADING_STATE_SELECTOR) page.has_css?(LOADING_STATE_SELECTOR)
end end
def showing_resets?
page.has_css?(RESETS_STATE_SELECTOR)
end
def not_showing_resets?
page.has_no_css?(RESETS_STATE_SELECTOR)
end
def has_custom_prompt? def has_custom_prompt?
page.has_css?(CUSTOM_PROMPT_SELECTOR) page.has_css?(CUSTOM_PROMPT_SELECTOR)
end end

View File

@ -11,8 +11,8 @@ module PageObjects
find(".d-modal__footer button.confirm", wait: 5).click find(".d-modal__footer button.confirm", wait: 5).click
end end
def revert_changes def discard_changes
find(".d-modal__footer button.revert", wait: 5).click find(".d-modal__footer button.discard", wait: 5).click
end end
def old_value def old_value