This takes the logic used in summarization/discoveries for streaming and consolidates it into a single helper lib for smooth streaming. It introduces a new lib: `SmoothStreamer` that can be used by components for smooth streaming text from message bus updates. Additionally, the PR makes use of that new lib in the AI post menu helper.
420 lines
12 KiB
Plaintext
420 lines
12 KiB
Plaintext
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 { withPluginApi } from "discourse/lib/plugin-api";
|
|
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
|
|
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;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
|
|
withPluginApi((api) => {
|
|
api.registerValueTransformer(
|
|
"post-text-selection-prevent-close",
|
|
({ value }) => {
|
|
if (this.menuState === this.MENU_STATES.result) {
|
|
return true;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
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() {
|
|
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
|
|
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.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(
|
|
"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();
|
|
}
|
|
}
|
|
}
|
|
|
|
<template>
|
|
{{#if
|
|
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
|
|
}}
|
|
<div class="ai-post-helper-menu__selected-text">
|
|
{{@data.selectedText}}
|
|
</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>
|
|
}
|