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>
This commit is contained in:
Joffrey JAFFEUX 2025-06-19 11:56:00 +02:00 committed by GitHub
parent 37dbd48513
commit 6a33e5154d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 123 additions and 176 deletions

View File

@ -14,7 +14,6 @@ import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators"; import { bind } from "discourse/lib/decorators";
import { withPluginApi } from "discourse/lib/plugin-api";
import { sanitize } from "discourse/lib/text"; import { sanitize } from "discourse/lib/text";
import { clipboardCopy } from "discourse/lib/utilities"; import { clipboardCopy } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
@ -44,6 +43,9 @@ export default class AiPostHelperMenu extends Component {
@tracked lastSelectedOption = null; @tracked lastSelectedOption = null;
@tracked isSavingFootnote = false; @tracked isSavingFootnote = false;
@tracked supportsAddFootnote = this.args.data.supportsFastEdit; @tracked supportsAddFootnote = this.args.data.supportsFastEdit;
@tracked
channel =
`/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
@tracked @tracked
smoothStreamer = new SmoothStreamer( smoothStreamer = new SmoothStreamer(
@ -78,23 +80,6 @@ export default class AiPostHelperMenu extends Component {
@tracked _activeAiRequest = null; @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() { get footnoteDisabled() {
return this.streaming || !this.supportsAddFootnote; return this.streaming || !this.supportsAddFootnote;
} }
@ -167,16 +152,17 @@ export default class AiPostHelperMenu extends Component {
@bind @bind
subscribe() { subscribe() {
const channel = `/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`; this.messageBus.subscribe(
this.messageBus.subscribe(channel, this._updateResult); this.channel,
(data) => this._updateResult(data),
this.args.data.post
.discourse_ai_helper_stream_suggestion_last_message_bus_id
);
} }
@bind @bind
unsubscribe() { unsubscribe() {
this.messageBus.unsubscribe( this.messageBus.unsubscribe(this.channel, this._updateResult);
"/discourse-ai/ai-helper/stream_suggestion/*",
this._updateResult
);
} }
@bind @bind
@ -239,7 +225,7 @@ export default class AiPostHelperMenu extends Component {
data: { data: {
location: "post", location: "post",
mode: option.name, mode: option.name,
text: this.args.data.selectedText, text: this.args.data.quoteState.buffer,
post_id: this.args.data.quoteState.postId, post_id: this.args.data.quoteState.postId,
custom_prompt: this.customPromptValue, custom_prompt: this.customPromptValue,
client_id: this.messageBus.clientId, client_id: this.messageBus.clientId,
@ -292,12 +278,10 @@ export default class AiPostHelperMenu extends Component {
@action @action
closeMenu() { closeMenu() {
if (this.site.mobileView) { // reset state and close
return this.args.close(); this.suggestion = "";
} this.customPromptValue = "";
return this.args.close();
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
return menu?.close();
} }
@action @action
@ -317,9 +301,9 @@ export default class AiPostHelperMenu extends Component {
const credits = i18n( const credits = i18n(
"discourse_ai.ai_helper.post_options_menu.footnote_credits" "discourse_ai.ai_helper.post_options_menu.footnote_credits"
); );
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`; const withFootnote = `${this.args.data.quoteState.buffer} ^[${sanitizedSuggestion} (${credits})]`;
const newRaw = result.raw.replace( const newRaw = result.raw.replace(
this.args.data.selectedText, this.args.data.quoteState.buffer,
withFootnote withFootnote
); );
@ -338,7 +322,7 @@ export default class AiPostHelperMenu extends Component {
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options)) (and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
}} }}
<div class="ai-post-helper-menu__selected-text"> <div class="ai-post-helper-menu__selected-text">
{{@data.selectedText}} {{@data.quoteState.buffer}}
</div> </div>
{{/if}} {{/if}}

View File

@ -3,8 +3,7 @@ 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 DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range"; import { selectedRange } from "discourse/lib/utilities";
import eq from "truth-helpers/helpers/eq";
import AiPostHelperMenu from "../../components/ai-post-helper-menu"; import AiPostHelperMenu from "../../components/ai-post-helper-menu";
import { showPostAIHelper } from "../../lib/show-ai-helper"; import { showPostAIHelper } from "../../lib/show-ai-helper";
@ -13,27 +12,27 @@ export default class AiPostHelperTrigger extends Component {
return showPostAIHelper(outletArgs, helper); return showPostAIHelper(outletArgs, helper);
} }
@service site;
@service menu; @service menu;
@tracked menuState = this.MENU_STATES.triggers;
@tracked showMainButtons = true;
@tracked showAiButtons = true;
@tracked postHighlighted = false; @tracked postHighlighted = false;
@tracked currentMenu = this.menu.getByIdentifier( @tracked currentMenu = this.menu.getByIdentifier(
"post-text-selection-toolbar" "post-text-selection-toolbar"
); );
MENU_STATES = { // capture the state at the moment the toolbar is rendered
triggers: "TRIGGERS", // so we ensure change of state (selection change for example)
options: "OPTIONS", // is not impacting the menu data
menuData = {
...this.args.outletArgs.data,
quoteState: {
buffer: this.args.outletArgs.data.quoteState.buffer,
opts: this.args.outletArgs.data.quoteState.opts,
postId: this.args.outletArgs.data.quoteState.postId,
},
post: this.args.outletArgs.post,
selectedRange: selectedRange(),
}; };
willDestroy() {
super.willDestroy(...arguments);
this.removeHighlightedText();
}
highlightSelectedText() { highlightSelectedText() {
const postId = this.args.outletArgs.data.quoteState.postId; const postId = this.args.outletArgs.data.quoteState.postId;
const postElement = document.querySelector( const postElement = document.querySelector(
@ -44,14 +43,7 @@ export default class AiPostHelperTrigger extends Component {
return; return;
} }
this.selectedText = this.args.outletArgs.data.quoteState.buffer; const range = this.menuData.selectedRange;
const selection = window.getSelection();
if (!selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
// Split start/end text nodes at their range boundary // Split start/end text nodes at their range boundary
if ( if (
@ -97,11 +89,10 @@ export default class AiPostHelperTrigger extends Component {
// Replace textNode with highlighted clone // Replace textNode with highlighted clone
const clone = textNode.cloneNode(true); const clone = textNode.cloneNode(true);
highlight.appendChild(clone); highlight.appendChild(clone);
textNode.parentNode.replaceChild(highlight, textNode); textNode.parentNode.replaceChild(highlight, textNode);
} }
selection.removeAllRanges(); window.getSelection().removeAllRanges();
this.postHighlighted = true; this.postHighlighted = true;
} }
@ -110,16 +101,7 @@ export default class AiPostHelperTrigger extends Component {
return; return;
} }
const postId = this.args.outletArgs.data.quoteState.postId; const highlightedSpans = document.querySelectorAll(
const postElement = document.querySelector(
`article[data-post-id='${postId}'] .cooked`
);
if (!postElement) {
return;
}
const highlightedSpans = postElement.querySelectorAll(
"span.ai-helper-highlighted-selection" "span.ai-helper-highlighted-selection"
); );
@ -133,65 +115,40 @@ export default class AiPostHelperTrigger extends Component {
@action @action
async showAiPostHelperMenu() { async showAiPostHelperMenu() {
this.highlightSelectedText(); await this.currentMenu.close();
if (this.site.mobileView) {
this.currentMenu.close();
await this.menu.show(virtualElementFromTextRange(), { await this.menu.show(this.currentMenu.trigger, {
identifier: "ai-post-helper-menu", identifier: "ai-post-helper-menu",
component: AiPostHelperMenu, component: AiPostHelperMenu,
inline: true, interactive: true,
interactive: true, trapTab: false,
placement: this.shouldRenderUnder ? "bottom-start" : "top-start", closeOnScroll: false,
fallbackPlacements: this.shouldRenderUnder modalForMobile: true,
? ["bottom-end", "top-start"] data: this.menuData,
: ["bottom-start"], placement: "top-start",
trapTab: false, fallbackPlacements: ["bottom-start"],
closeOnScroll: false, updateOnScroll: false,
modalForMobile: true, onClose: () => {
data: this.menuData, this.removeHighlightedText();
});
}
this.showMainButtons = false;
this.menuState = this.MENU_STATES.options;
}
get menuData() {
// Streamline of data model to be passed to the component when
// instantiated as a DMenu or a simple component in the template
return {
...this.args.outletArgs.data,
quoteState: {
buffer: this.args.outletArgs.data.quoteState.buffer,
opts: this.args.outletArgs.data.quoteState.opts,
postId: this.args.outletArgs.data.quoteState.postId,
}, },
post: this.args.outletArgs.post, });
selectedText: this.selectedText,
}; await this.currentMenu.destroy();
this.highlightSelectedText();
} }
<template> <template>
{{#if this.showMainButtons}} {{yield}}
{{yield}}
{{/if}}
{{#if this.showAiButtons}} <div class="ai-post-helper">
<div class="ai-post-helper"> <DButton
{{#if (eq this.menuState this.MENU_STATES.triggers)}} @icon="discourse-sparkles"
<DButton @title="discourse_ai.ai_helper.post_options_menu.title"
@icon="discourse-sparkles" @label="discourse_ai.ai_helper.post_options_menu.trigger"
@title="discourse_ai.ai_helper.post_options_menu.title" @action={{this.showAiPostHelperMenu}}
@label="discourse_ai.ai_helper.post_options_menu.trigger" class="btn-flat ai-post-helper__trigger"
@action={{this.showAiPostHelperMenu}} />
class="btn-flat ai-post-helper__trigger" </div>
/>
{{else if (eq this.menuState this.MENU_STATES.options)}}
<AiPostHelperMenu @data={{this.menuData}} />
{{/if}}
</div>
{{/if}}
</template> </template>
} }

View File

@ -1,45 +0,0 @@
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);
}

View File

@ -73,6 +73,12 @@ module DiscourseAi
scope.user.in_any_groups?(SiteSetting.ai_auto_image_caption_allowed_groups_map) scope.user.in_any_groups?(SiteSetting.ai_auto_image_caption_allowed_groups_map)
end, end,
) { object.auto_image_caption } ) { object.auto_image_caption }
plugin.add_to_serializer(
:post,
:discourse_ai_helper_stream_suggestion_last_message_bus_id,
include_condition: -> { SiteSetting.ai_helper_enabled && scope.authenticated? },
) { MessageBus.last_id("/discourse-ai/ai-helper/stream_suggestion/#{object.id}") }
end end
end end
end end

View File

@ -30,6 +30,7 @@ RSpec.describe "AI Post helper", type: :system, js: true do
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
assign_fake_provider_to(:ai_helper_model) assign_fake_provider_to(:ai_helper_model)
SiteSetting.ai_helper_enabled = true SiteSetting.ai_helper_enabled = true
Jobs.run_immediately!
sign_in(user) sign_in(user)
end end
@ -87,13 +88,52 @@ RSpec.describe "AI Post helper", type: :system, js: true do
end end
it "pre-fills fast edit with proofread text" do it "pre-fills fast edit with proofread text" do
skip("Test is flaky in CI, possibly some timing issue?") if ENV["CI"]
select_post_text(post_3) select_post_text(post_3)
post_ai_helper.click_ai_button post_ai_helper.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do
post_ai_helper.select_helper_model(mode) post_ai_helper.select_helper_model(mode)
wait_for { fast_editor.has_content?(proofread_response) } expect(page.find("#fast-edit-input")).to have_content(proofread_response)
expect(fast_editor).to have_content(proofread_response) end
end
end
context "when using explain mode" do
let(:mode) { DiscourseAi::AiHelper::Assistant::EXPLAIN }
let(:explain_response) { "This is about pie." }
it "shows the explanation in the AI helper" do
select_post_text(post)
post_ai_helper.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([explain_response]) do
post_ai_helper.select_helper_model(mode)
expect(post_ai_helper).to have_suggestion_value(explain_response)
end
end
context "with footnotes enabled" do
before do
SiteSetting.enable_markdown_footnotes = true
SiteSetting.display_footnotes_inline = true
end
it "allows adding the explanation as a footnote to the post" do
select_post_text(post)
post_ai_helper.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([explain_response]) do
post_ai_helper.select_helper_model(mode)
expect(post_ai_helper).to have_suggestion_value(explain_response)
post_ai_helper.click_add_footnote
expect(post_ai_helper).to have_no_post_ai_helper
expect(post.reload.raw).to include(
"^[#{explain_response} (#{I18n.t("js.discourse_ai.ai_helper.post_options_menu.footnote_credits")})]",
)
end
end end
end end
end end
@ -128,13 +168,11 @@ RSpec.describe "AI Post helper", type: :system, js: true do
end end
it "pre-fills fast edit with proofread text" do it "pre-fills fast edit with proofread text" do
skip("Test is flaky in CI, possibly some timing issue?") if ENV["CI"]
select_post_text(post_3) select_post_text(post_3)
find(".quote-edit-label").click find(".quote-edit-label").click
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do
find(".btn-ai-suggest-edit", visible: :all).click find(".btn-ai-suggest-edit", visible: :all).click
wait_for { fast_editor.has_content?(proofread_response) } expect(page.find("#fast-edit-input")).to have_content(proofread_response)
expect(fast_editor).to have_content(proofread_response)
end end
end end
end end

View File

@ -9,7 +9,6 @@ module PageObjects
SHARE_SELECTOR = ".quote-sharing" SHARE_SELECTOR = ".quote-sharing"
AI_HELPER_SELECTOR = ".ai-post-helper" AI_HELPER_SELECTOR = ".ai-post-helper"
AI_HELPER_MOBILE_SELECTOR = ".ai-post-helper-menu-content"
TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger" TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger"
OPTIONS_SELECTOR = ".ai-helper-options" OPTIONS_SELECTOR = ".ai-helper-options"
LOADING_SELECTOR = ".ai-helper-context-menu__loading" LOADING_SELECTOR = ".ai-helper-context-menu__loading"
@ -28,6 +27,10 @@ module PageObjects
find("#{OPTIONS_SELECTOR} .btn[data-name=\"#{mode}\"]").click find("#{OPTIONS_SELECTOR} .btn[data-name=\"#{mode}\"]").click
end end
def has_suggestion_value?(value)
page.has_css?("#{SUGGESTION_SELECTOR}__text", text: value)
end
def suggestion_value def suggestion_value
find("#{SUGGESTION_SELECTOR}__text").text find("#{SUGGESTION_SELECTOR}__text").text
end end
@ -41,11 +44,11 @@ module PageObjects
end end
def has_mobile_post_ai_helper? def has_mobile_post_ai_helper?
page.has_css?(AI_HELPER_MOBILE_SELECTOR) page.has_css?(".fk-d-menu-modal #{AI_HELPER_SELECTOR}")
end end
def has_no_mobile_post_ai_helper? def has_no_mobile_post_ai_helper?
page.has_no_css?(AI_HELPER_MOBILE_SELECTOR) page.has_no_css?(".fk-d-menu-modal #{AI_HELPER_SELECTOR}")
end end
def has_post_ai_helper? def has_post_ai_helper?
@ -81,6 +84,10 @@ module PageObjects
page.has_no_css?(QUOTE_SELECTOR) || page.has_no_css?(EDIT_SELECTOR) || page.has_no_css?(QUOTE_SELECTOR) || page.has_no_css?(EDIT_SELECTOR) ||
page.has_no_css?(SHARE_SELECTOR) page.has_no_css?(SHARE_SELECTOR)
end end
def has_suggestions?
page.has_css?(SUGGESTION_SELECTOR)
end
end end
end end
end end