Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 13 additions & 23 deletions assets/javascripts/discourse/components/ai-post-helper-menu.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ 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";
Expand Down Expand Up @@ -77,23 +76,7 @@ export default class AiPostHelperMenu extends Component {
});

@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;
}
);
});
}
@tracked _lastMessageIds = {};

get footnoteDisabled() {
return this.streaming || !this.supportsAddFootnote;
Expand Down Expand Up @@ -168,7 +151,14 @@ export default class AiPostHelperMenu extends Component {
@bind
subscribe() {
const channel = `/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
this.messageBus.subscribe(channel, this._updateResult);
this.messageBus.subscribe(
channel,
(data, id) => {
this._lastMessageIds[channel] = id;
this._updateResult(data, id);
},
this._lastMessageIds[channel]
);
}

@bind
Expand Down Expand Up @@ -239,7 +229,7 @@ export default class AiPostHelperMenu extends Component {
data: {
location: "post",
mode: option.name,
text: this.args.data.selectedText,
text: this.args.data.quoteState.buffer,
post_id: this.args.data.quoteState.postId,
custom_prompt: this.customPromptValue,
client_id: this.messageBus.clientId,
Expand Down Expand Up @@ -317,9 +307,9 @@ export default class AiPostHelperMenu extends Component {
const credits = i18n(
"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(
this.args.data.selectedText,
this.args.data.quoteState.buffer,
withFootnote
);

Expand All @@ -338,7 +328,7 @@ export default class AiPostHelperMenu extends Component {
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
}}
<div class="ai-post-helper-menu__selected-text">
{{@data.selectedText}}
{{@data.quoteState.buffer}}
</div>
{{/if}}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
import eq from "truth-helpers/helpers/eq";
import { selectedRange } from "discourse/lib/utilities";
import AiPostHelperMenu from "../../components/ai-post-helper-menu";
import { showPostAIHelper } from "../../lib/show-ai-helper";

Expand All @@ -13,27 +12,27 @@ export default class AiPostHelperTrigger extends Component {
return showPostAIHelper(outletArgs, helper);
}

@service site;
@service menu;

@tracked menuState = this.MENU_STATES.triggers;
@tracked showMainButtons = true;
@tracked showAiButtons = true;
@tracked postHighlighted = false;
@tracked currentMenu = this.menu.getByIdentifier(
"post-text-selection-toolbar"
);

MENU_STATES = {
triggers: "TRIGGERS",
options: "OPTIONS",
// capture the state at the moment the toolbar is rendered
// so we ensure change of state (selection change for example)
// 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() {
const postId = this.args.outletArgs.data.quoteState.postId;
const postElement = document.querySelector(
Expand All @@ -44,14 +43,7 @@ export default class AiPostHelperTrigger extends Component {
return;
}

this.selectedText = this.args.outletArgs.data.quoteState.buffer;

const selection = window.getSelection();
if (!selection.rangeCount) {
return;
}

const range = selection.getRangeAt(0);
const range = this.menuData.selectedRange;

// Split start/end text nodes at their range boundary
if (
Expand Down Expand Up @@ -97,11 +89,10 @@ export default class AiPostHelperTrigger extends Component {
// Replace textNode with highlighted clone
const clone = textNode.cloneNode(true);
highlight.appendChild(clone);

textNode.parentNode.replaceChild(highlight, textNode);
}

selection.removeAllRanges();
window.getSelection().removeAllRanges();
this.postHighlighted = true;
}

Expand All @@ -110,16 +101,7 @@ export default class AiPostHelperTrigger extends Component {
return;
}

const postId = this.args.outletArgs.data.quoteState.postId;
const postElement = document.querySelector(
`article[data-post-id='${postId}'] .cooked`
);

if (!postElement) {
return;
}

const highlightedSpans = postElement.querySelectorAll(
const highlightedSpans = document.querySelectorAll(
"span.ai-helper-highlighted-selection"
);

Expand All @@ -133,65 +115,44 @@ export default class AiPostHelperTrigger extends Component {

@action
async showAiPostHelperMenu() {
this.highlightSelectedText();
if (this.site.mobileView) {
this.currentMenu.close();

await this.menu.show(virtualElementFromTextRange(), {
identifier: "ai-post-helper-menu",
component: AiPostHelperMenu,
inline: true,
interactive: true,
placement: this.shouldRenderUnder ? "bottom-start" : "top-start",
fallbackPlacements: this.shouldRenderUnder
? ["bottom-end", "top-start"]
: ["bottom-start"],
trapTab: false,
closeOnScroll: false,
modalForMobile: true,
data: this.menuData,
});
}

this.showMainButtons = false;
this.menuState = this.MENU_STATES.options;
}
const existingRect = this.currentMenu.trigger.rect;
const virtualElement = {
getBoundingClientRect: () => existingRect,
getClientRects: () => [existingRect],
};

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,
await this.currentMenu.close();

await this.menu.show(virtualElement, {
identifier: "ai-post-helper-menu",
component: AiPostHelperMenu,
interactive: true,
trapTab: false,
closeOnScroll: false,
modalForMobile: true,
data: this.menuData,
placement: "top-start",
fallbackPlacements: ["bottom-start"],
inline: true,
onClose: () => {
this.removeHighlightedText();
},
post: this.args.outletArgs.post,
selectedText: this.selectedText,
};
});

this.highlightSelectedText();
}

<template>
{{#if this.showMainButtons}}
{{yield}}
{{/if}}

{{#if this.showAiButtons}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.triggers)}}
<DButton
@icon="discourse-sparkles"
@title="discourse_ai.ai_helper.post_options_menu.title"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAiPostHelperMenu}}
class="btn-flat ai-post-helper__trigger"
/>

{{else if (eq this.menuState this.MENU_STATES.options)}}
<AiPostHelperMenu @data={{this.menuData}} />
{{/if}}
</div>
{{/if}}
{{yield}}

<div class="ai-post-helper">
<DButton
@icon="discourse-sparkles"
@title="discourse_ai.ai_helper.post_options_menu.title"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAiPostHelperMenu}}
class="btn-flat ai-post-helper__trigger"
/>
</div>
</template>
}

This file was deleted.

32 changes: 26 additions & 6 deletions spec/system/ai_helper/ai_post_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
assign_fake_provider_to(:ai_helper_model)
SiteSetting.ai_helper_enabled = true
Jobs.run_immediately!
sign_in(user)
end

Expand Down Expand Up @@ -87,13 +88,34 @@ def select_post_text(selected_post)
end

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)
post_ai_helper.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do
post_ai_helper.select_helper_model(mode)
wait_for { fast_editor.has_content?(proofread_response) }
expect(fast_editor).to have_content(proofread_response)
expect(page.find("#fast-edit-input")).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)

MessageBus.publish(
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
{ result: explain_response, done: true },
user_ids: [user.id],
)

wait_for { post_ai_helper.has_suggestion_value? }
expect(post_ai_helper.suggestion_value).to eq(explain_response)
end
end
end
Expand Down Expand Up @@ -128,13 +150,11 @@ def select_post_text(selected_post)
end

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)
find(".quote-edit-label").click
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_response]) do
find(".btn-ai-suggest-edit", visible: :all).click
wait_for { fast_editor.has_content?(proofread_response) }
expect(fast_editor).to have_content(proofread_response)
expect(page.find("#fast-edit-input")).to have_content(proofread_response)
end
end
end
Expand Down
Loading
Loading