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 { inject as service } from "@ember/service"; 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 { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { sanitize } from "discourse/lib/text"; import { clipboardCopy } from "discourse/lib/utilities"; import i18n from "discourse-common/helpers/i18n"; import { bind } from "discourse-common/utils/decorators"; 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"; export default class AiPostHelperMenu extends Component { @service messageBus; @service site; @service modal; @service siteSettings; @service currentUser; @service menu; @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; MENU_STATES = { options: "OPTIONS", loading: "LOADING", result: "RESULT", }; @tracked _activeAiRequest = null; 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() { const channel = `/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`; this.messageBus.subscribe(channel, this._updateResult); } @bind unsubscribe() { this.messageBus.unsubscribe( "/discourse-ai/ai-helper/stream_suggestion/*", this._updateResult ); } @bind _updateResult(result) { this.streaming = !result.done; this.suggestion = 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.id, 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: { mode: option.id, text: this.args.data.selectedText, post_id: this.args.data.quoteState.postId, custom_prompt: this.customPromptValue, }, }); 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() { if (this.site.mobileView) { return this.args.close(); } const menu = this.menu.getByIdentifier("post-text-selection-toolbar"); return menu?.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.t( "discourse_ai.ai_helper.post_options_menu.footnote_credits" ); const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`; const newRaw = result.raw.replace( this.args.data.selectedText, withFootnote ); await this.args.data.post.save({ raw: newRaw }); } catch (error) { popupAjaxError(error); } finally { this.isSavingFootnote = false; await this.closeMenu(); } } } }