discourse-ai/assets/javascripts/discourse/components/ai-post-helper-menu.gjs
Joffrey JAFFEUX 6a33e5154d
DEV: makes ai menu helper a standalone menu (#1434)
The current menu was rendering inside the post text toolbar (on desktop). This is not ideal as the post text toolbar rendering is conditioned on the presence of text selection, when you click a button on the toolbar, by design of the web browsers you will lose your text selection, making all of this super tricky.

This commit makes desktop and mobile behave in the same way by rendering their own menu and capturing the quote state when we render the post text selection toolbar, this allows us to reason a much simpler way about the AI helper.

This commit also removes what appears to be an unused file and corrects which was seemingly copy/paste mistakes.

⚠️ Technical note, this commit is correcting the message bus subscription which amongst other things allows to write specs which are not flaky. However due to the current implementation we have a channel per post, which means we need to serialize on last message bus id per post. 

We have two possible solutions here:
- subscribe at the topic level
- refactor the code to be able to use `MessageBus.last_ids` to be able to grab multiple posts at once instead of having to call `MessageBus.last_id` and done one Redis call per post

---------

Co-authored-by: Keegan George <kgeorge13@gmail.com>
2025-06-19 11:56:00 +02:00

405 lines
12 KiB
Text

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import FastEdit from "discourse/components/fast-edit";
import FastEditModal from "discourse/components/modal/fast-edit";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { sanitize } from "discourse/lib/text";
import { clipboardCopy } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import eq from "truth-helpers/helpers/eq";
import AiHelperLoading from "../components/ai-helper-loading";
import AiHelperOptionsList from "../components/ai-helper-options-list";
import SmoothStreamer from "../lib/smooth-streamer";
export default class AiPostHelperMenu extends Component {
@service messageBus;
@service site;
@service modal;
@service siteSettings;
@service currentUser;
@service menu;
@service tooltip;
@tracked menuState = this.MENU_STATES.options;
@tracked loading = false;
@tracked suggestion = "";
@tracked customPromptValue = "";
@tracked copyButtonIcon = "copy";
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
@tracked showFastEdit = false;
@tracked showAiButtons = true;
@tracked streaming = false;
@tracked lastSelectedOption = null;
@tracked isSavingFootnote = false;
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
@tracked
channel =
`/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
@tracked
smoothStreamer = new SmoothStreamer(
() => this.suggestion,
(newValue) => (this.suggestion = newValue)
);
MENU_STATES = {
options: "OPTIONS",
loading: "LOADING",
result: "RESULT",
};
showFootnoteTooltip = modifier((element) => {
if (this.supportsAddFootnote || this.streaming) {
return;
}
const instance = this.tooltip.register(element, {
identifier: "cannot-add-footnote-tooltip",
content: i18n(
"discourse_ai.ai_helper.post_options_menu.footnote_disabled"
),
placement: "top",
triggers: "hover",
});
return () => {
instance.destroy();
};
});
@tracked _activeAiRequest = null;
get footnoteDisabled() {
return this.streaming || !this.supportsAddFootnote;
}
get helperOptions() {
let prompts = this.currentUser?.ai_helper_prompts;
prompts = prompts.filter((item) => item.location.includes("post"));
// 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");
}
if (!this.args.data.canEditPost) {
prompts = prompts.filter((p) => p.name !== "proofread");
}
return prompts;
}
get highlightedTextToggleIcon() {
if (this.showHighlightedText) {
return "angle-double-left";
} else {
return "angle-double-right";
}
}
get allowInsertFootnote() {
const siteSettings = this.siteSettings;
const canEditPost = this.args.data.canEditPost;
if (
!siteSettings?.enable_markdown_footnotes ||
!siteSettings?.display_footnotes_inline ||
!canEditPost
) {
return false;
}
return this.lastSelectedOption?.name === "explain";
}
_showUserCustomPrompts() {
return this.currentUser?.can_use_custom_prompts;
}
_sanitizeForFootnote(text) {
// Remove line breaks (line-breaks breaks the inline footnote display)
text = text.replace(/[\r\n]+/g, " ");
// Remove headings (headings don't work in inline footnotes)
text = text.replace(/^(#+)\s+/gm, "");
// Trim excess space
text = text.trim();
return sanitize(text);
}
@bind
subscribe() {
this.messageBus.subscribe(
this.channel,
(data) => this._updateResult(data),
this.args.data.post
.discourse_ai_helper_stream_suggestion_last_message_bus_id
);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(this.channel, this._updateResult);
}
@bind
async _updateResult(result) {
this.streaming = !result.done;
await this.smoothStreamer.updateResult(result, "result");
}
@action
toggleHighlightedTextPreview() {
this.showHighlightedText = !this.showHighlightedText;
}
@action
async performAiSuggestion(option) {
this.menuState = this.MENU_STATES.loading;
this.lastSelectedOption = option;
const streamableOptions = ["explain", "translate", "custom_prompt"];
if (streamableOptions.includes(option.name)) {
return this._handleStreamedResult(option);
} else {
this._activeAiRequest = ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: option.name,
text: this.args.data.quoteState.buffer,
custom_prompt: this.customPromptValue,
},
});
}
this._activeAiRequest
.then(({ suggestions }) => {
this.suggestion = suggestions[0].trim();
if (option.name === "proofread") {
return this._handleProofreadOption();
}
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
this.menuState = this.MENU_STATES.result;
});
return this._activeAiRequest;
}
_handleStreamedResult(option) {
this.menuState = this.MENU_STATES.result;
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
if (menu) {
menu.options.placement = "bottom";
}
const fetchUrl = `/discourse-ai/ai-helper/stream_suggestion`;
this._activeAiRequest = ajax(fetchUrl, {
method: "POST",
data: {
location: "post",
mode: option.name,
text: this.args.data.quoteState.buffer,
post_id: this.args.data.quoteState.postId,
custom_prompt: this.customPromptValue,
client_id: this.messageBus.clientId,
},
});
return this._activeAiRequest;
}
_handleProofreadOption() {
this.showAiButtons = false;
if (this.site.desktopView) {
this.showFastEdit = true;
return;
} else {
return this.modal.show(FastEditModal, {
model: {
initialValue: this.args.data.quoteState.buffer,
newValue: this.suggestion,
post: this.args.data.post,
close: this.closeFastEdit,
},
});
}
}
@action
cancelAiAction() {
if (this._activeAiRequest) {
this._activeAiRequest.abort();
this._activeAiRequest = null;
this.loading = false;
this.menuState = this.MENU_STATES.options;
}
}
@action
copySuggestion() {
if (this.suggestion?.length > 0) {
clipboardCopy(this.suggestion);
this.copyButtonIcon = "check";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copied";
setTimeout(() => {
this.copyButtonIcon = "copy";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
}, 3500);
}
}
@action
closeMenu() {
// reset state and close
this.suggestion = "";
this.customPromptValue = "";
return this.args.close();
}
@action
async closeFastEdit() {
this.showFastEdit = false;
await this.args.data.hideToolbar();
}
@action
async insertFootnote() {
this.isSavingFootnote = true;
if (this.allowInsertFootnote) {
try {
const result = await ajax(`/posts/${this.args.data.post.id}`);
const sanitizedSuggestion = this._sanitizeForFootnote(this.suggestion);
const credits = i18n(
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
);
const withFootnote = `${this.args.data.quoteState.buffer} ^[${sanitizedSuggestion} (${credits})]`;
const newRaw = result.raw.replace(
this.args.data.quoteState.buffer,
withFootnote
);
await this.args.data.post.save({ raw: newRaw });
} catch (error) {
popupAjaxError(error);
} finally {
this.isSavingFootnote = false;
await this.closeMenu();
}
}
}
<template>
{{#if
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
}}
<div class="ai-post-helper-menu__selected-text">
{{@data.quoteState.buffer}}
</div>
{{/if}}
{{#if this.showAiButtons}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.options)}}
<AiHelperOptionsList
@options={{this.helperOptions}}
@customPromptValue={{this.customPromptValue}}
@performAction={{this.performAiSuggestion}}
@shortcutVisible={{false}}
/>
{{else if (eq this.menuState this.MENU_STATES.loading)}}
<AiHelperLoading @cancel={{this.cancelAiAction}} />
{{else if (eq this.menuState this.MENU_STATES.result)}}
<div
class="ai-post-helper__suggestion"
{{didInsert this.subscribe}}
{{willDestroy this.unsubscribe}}
>
{{#if this.suggestion}}
<div
class={{concatClass
(if this.smoothStreamer.isStreaming "streaming")
"streamable-content"
"ai-post-helper__suggestion__text"
}}
dir="auto"
>
<CookText
@rawText={{this.smoothStreamer.renderedText}}
class="cooked"
/>
</div>
<div class="ai-post-helper__suggestion__buttons">
<DButton
@icon="xmark"
@label="discourse_ai.ai_helper.post_options_menu.cancel"
@action={{this.cancelAiAction}}
class="btn-flat ai-post-helper__suggestion__cancel"
/>
<DButton
@icon={{this.copyButtonIcon}}
@label={{this.copyButtonLabel}}
@action={{this.copySuggestion}}
@disabled={{this.streaming}}
class="btn-flat ai-post-helper__suggestion__copy"
/>
{{#if this.allowInsertFootnote}}
<DButton
@icon="asterisk"
@label="discourse_ai.ai_helper.post_options_menu.insert_footnote"
@action={{this.insertFootnote}}
@isLoading={{this.isSavingFootnote}}
@disabled={{this.footnoteDisabled}}
class="btn-flat ai-post-helper__suggestion__insert-footnote"
{{this.showFootnoteTooltip}}
/>
{{/if}}
</div>
{{else}}
<AiHelperLoading @cancel={{this.cancelAiAction}} />
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
{{#if this.showFastEdit}}
<div class="ai-post-helper__fast-edit">
<FastEdit
@initialValue={{@data.quoteState.buffer}}
@newValue={{this.suggestion}}
@post={{@data.post}}
@close={{this.closeFastEdit}}
/>
</div>
{{/if}}
</template>
}