Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit d6beac4

Browse files
authored
DEV: Improve explain suggestion footnote replacement (#999)
Previously, when clicking add footnote on an explain suggestion it would replace the selected word by finding the first occurrence of the word. This results in issues when there are more than one occurrences of a word in a post. This is not trivial to solve, so this PR instead prevents incorrect text replacements by only allowing the replacement if it's unique. We use the same logic here that we use to determine if something can be fast edited. In this PR we also update tests for post helper explain suggestions. For a while, we haven't had tests here due to streaming/timing issues, we've been skipping our system specs. In this PR, we add acceptance tests to handle this which gives us improved ability to publish message bus updates in the testing environment so that it can be better tested without issues.
1 parent 938d4c0 commit d6beac4

File tree

6 files changed

+212
-73
lines changed

6 files changed

+212
-73
lines changed

assets/javascripts/discourse/components/ai-post-helper-menu.gjs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { action } from "@ember/object";
44
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
55
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
66
import { service } from "@ember/service";
7+
import { modifier } from "ember-modifier";
78
import { and } from "truth-helpers";
89
import CookText from "discourse/components/cook-text";
910
import DButton from "discourse/components/d-button";
@@ -26,6 +27,7 @@ export default class AiPostHelperMenu extends Component {
2627
@service siteSettings;
2728
@service currentUser;
2829
@service menu;
30+
@service tooltip;
2931

3032
@tracked menuState = this.MENU_STATES.options;
3133
@tracked loading = false;
@@ -38,15 +40,39 @@ export default class AiPostHelperMenu extends Component {
3840
@tracked streaming = false;
3941
@tracked lastSelectedOption = null;
4042
@tracked isSavingFootnote = false;
43+
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
4144

4245
MENU_STATES = {
4346
options: "OPTIONS",
4447
loading: "LOADING",
4548
result: "RESULT",
4649
};
4750

51+
showFootnoteTooltip = modifier((element) => {
52+
if (this.supportsAddFootnote || this.streaming) {
53+
return;
54+
}
55+
56+
const instance = this.tooltip.register(element, {
57+
identifier: "cannot-add-footnote-tooltip",
58+
content: I18n.t(
59+
"discourse_ai.ai_helper.post_options_menu.footnote_disabled"
60+
),
61+
placement: "top",
62+
triggers: "hover",
63+
});
64+
65+
return () => {
66+
instance.destroy();
67+
};
68+
});
69+
4870
@tracked _activeAiRequest = null;
4971

72+
get footnoteDisabled() {
73+
return this.streaming || !this.supportsAddFootnote;
74+
}
75+
5076
get helperOptions() {
5177
let prompts = this.currentUser?.ai_helper_prompts;
5278

@@ -329,8 +355,9 @@ export default class AiPostHelperMenu extends Component {
329355
@label="discourse_ai.ai_helper.post_options_menu.insert_footnote"
330356
@action={{this.insertFootnote}}
331357
@isLoading={{this.isSavingFootnote}}
332-
@disabled={{this.streaming}}
358+
@disabled={{this.footnoteDisabled}}
333359
class="btn-flat ai-post-helper__suggestion__insert-footnote"
360+
{{this.showFootnoteTooltip}}
334361
/>
335362
{{/if}}
336363
</div>

assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export default class AiPostHelperTrigger extends Component {
135135
identifier: "ai-post-helper-menu",
136136
component: AiPostHelperMenu,
137137
inline: true,
138+
interactive: true,
138139
placement: this.shouldRenderUnder ? "bottom-start" : "top-start",
139140
fallbackPlacements: this.shouldRenderUnder
140141
? ["bottom-end", "top-start"]

config/locales/client.en.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ en:
145145
response_tokens: "Response tokens"
146146
cached_tokens: "Cached tokens"
147147

148-
149148
ai_persona:
150149
tool_strategies:
151150
all: "Apply to all replies"
@@ -395,6 +394,7 @@ en:
395394
copied: "Copied!"
396395
cancel: "Cancel"
397396
insert_footnote: "Add footnote"
397+
footnote_disabled: "Automatic insertion disabled, click copy button and edit it in manually"
398398
footnote_credits: "Explanation by AI"
399399
fast_edit:
400400
suggest_button: "Suggest edit"

spec/system/ai_helper/ai_post_helper_spec.rb

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -79,77 +79,6 @@ def select_post_text(selected_post)
7979
expect(post.like_count).to eq(1)
8080
end
8181

82-
context "when using explain mode" do
83-
let(:mode) { CompletionPrompt::EXPLAIN }
84-
85-
let(:explain_response) { <<~STRING }
86-
In this context, pie refers to a baked dessert typically consisting of a pastry crust and filling.
87-
The person states they enjoy eating pie, considering it a good dessert. They note that some people wastefully
88-
throw pie at others, but the person themselves chooses to eat the pie rather than throwing it. Overall, pie
89-
is being used to refer the the baked dessert food item.
90-
STRING
91-
92-
skip "TODO: Streaming causing timing issue in test" do
93-
it "shows an explanation of the selected text" do
94-
select_post_text(post)
95-
post_ai_helper.click_ai_button
96-
97-
DiscourseAi::Completions::Llm.with_prepared_responses([explain_response]) do
98-
expected_value = explain_response.gsub(/"/, "").strip
99-
100-
post_ai_helper.select_helper_model(mode)
101-
Jobs.run_immediately!
102-
103-
wait_for(timeout: 10) do
104-
post_ai_helper.suggestion_value.gsub(/"/, "").strip == expected_value
105-
end
106-
107-
expect(post_ai_helper.suggestion_value.gsub(/"/, "").strip).to eq(expected_value)
108-
end
109-
end
110-
111-
it "adds explained text as footnote to post" do
112-
select_post_text(post)
113-
post_ai_helper.click_ai_button
114-
115-
DiscourseAi::Completions::Llm.with_prepared_responses([explain_response]) do
116-
expected_value = explain_response.gsub(/"/, "").strip
117-
118-
post_ai_helper.select_helper_model(mode)
119-
Jobs.run_immediately!
120-
121-
wait_for(timeout: 10) do
122-
post_ai_helper.suggestion_value.gsub(/"/, "").strip == expected_value
123-
end
124-
125-
post_ai_helper.click_add_footnote
126-
expect(page.has_css?(".expand-footnote")).to eq(true)
127-
end
128-
end
129-
end
130-
end
131-
132-
context "when using translate mode" do
133-
let(:mode) { CompletionPrompt::TRANSLATE }
134-
135-
let(:translated_input) { "The rain in Spain, stays mainly in the Plane." }
136-
137-
skip "TODO: Streaming causing timing issue in test" do
138-
it "shows a translation of the selected text" do
139-
select_post_text(post_2)
140-
post_ai_helper.click_ai_button
141-
142-
DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do
143-
post_ai_helper.select_helper_model(mode)
144-
145-
wait_for { post_ai_helper.suggestion_value == translated_input }
146-
147-
expect(post_ai_helper.suggestion_value).to eq(translated_input)
148-
end
149-
end
150-
end
151-
end
152-
15382
context "when using proofread mode" do
15483
let(:mode) { CompletionPrompt::PROOFREAD }
15584
let(:proofread_response) do
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { click, settled, visit } from "@ember/test-helpers";
2+
import { test } from "qunit";
3+
import { AUTO_GROUPS } from "discourse/lib/constants";
4+
import topicFixtures from "discourse/tests/fixtures/topic";
5+
import {
6+
acceptance,
7+
publishToMessageBus,
8+
query,
9+
selectText,
10+
} from "discourse/tests/helpers/qunit-helpers";
11+
import { cloneJSON } from "discourse-common/lib/object";
12+
import aiHelperPrompts from "../fixtures/ai-helper-prompts";
13+
14+
acceptance("AI Helper - Post Helper Menu", function (needs) {
15+
needs.settings({
16+
discourse_ai_enabled: true,
17+
ai_helper_enabled: true,
18+
post_ai_helper_allowed_groups: "1|2",
19+
ai_helper_enabled_features: "suggestions|context_menu",
20+
share_quote_visibility: "anonymous",
21+
enable_markdown_footnotes: true,
22+
display_footnotes_inline: true,
23+
});
24+
needs.user({
25+
admin: true,
26+
moderator: true,
27+
groups: [AUTO_GROUPS.admins],
28+
can_use_assistant_in_post: true,
29+
ai_helper_prompts: aiHelperPrompts,
30+
trust_level: 4,
31+
});
32+
needs.pretender((server, helper) => {
33+
server.get("/t/1.json", () => {
34+
const json = cloneJSON(topicFixtures["/t/28830/1.json"]);
35+
json.post_stream.posts[0].can_edit_post = true;
36+
json.post_stream.posts[0].can_edit = true;
37+
return helper.response(json);
38+
});
39+
40+
server.get("/t/2.json", () => {
41+
const json = cloneJSON(topicFixtures["/t/28830/1.json"]);
42+
json.post_stream.posts[0].cooked =
43+
"<p>La lluvia en España se queda principalmente en el avión.</p>";
44+
return helper.response(json);
45+
});
46+
47+
server.post(`/discourse-ai/ai-helper/stream_suggestion/`, () => {
48+
return helper.response({
49+
result: "This is a suggestio",
50+
done: false,
51+
});
52+
});
53+
});
54+
55+
test("displays streamed explanation", async function (assert) {
56+
await visit("/t/-/1");
57+
const suggestion = "This is a suggestion that is completed";
58+
const textNode = query("#post_1 .cooked p").childNodes[0];
59+
await selectText(textNode, 9);
60+
await click(".ai-post-helper__trigger");
61+
await click(".ai-helper-options__button[data-name='explain']");
62+
await publishToMessageBus(
63+
`/discourse-ai/ai-helper/stream_suggestion/118591`,
64+
{
65+
done: true,
66+
result: suggestion,
67+
}
68+
);
69+
assert.dom(".ai-post-helper__suggestion__text").hasText(suggestion);
70+
});
71+
72+
async function selectSpecificText(textNode, start, end) {
73+
const range = document.createRange();
74+
range.setStart(textNode, start);
75+
range.setEnd(textNode, end);
76+
const selection = window.getSelection();
77+
selection.removeAllRanges();
78+
selection.addRange(range);
79+
await settled();
80+
}
81+
82+
test("adds explained text as footnote to post", async function (assert) {
83+
await visit("/t/-/1");
84+
const suggestion = "This is a suggestion that is completed";
85+
86+
const textNode = query("#post_1 .cooked p").childNodes[0];
87+
await selectSpecificText(textNode, 72, 77);
88+
await click(".ai-post-helper__trigger");
89+
await click(".ai-helper-options__button[data-name='explain']");
90+
await publishToMessageBus(
91+
`/discourse-ai/ai-helper/stream_suggestion/118591`,
92+
{
93+
done: true,
94+
result: suggestion,
95+
}
96+
);
97+
98+
assert.dom(".ai-post-helper__suggestion__insert-footnote").isDisabled();
99+
});
100+
101+
test("shows translated post", async function (assert) {
102+
await visit("/t/-/2");
103+
const translated = "The rain in Spain, stays mainly in the Plane.";
104+
await selectText(query("#post_1 .cooked p"));
105+
await click(".ai-post-helper__trigger");
106+
await click(".ai-helper-options__button[data-name='translate']");
107+
await publishToMessageBus(
108+
`/discourse-ai/ai-helper/stream_suggestion/118591`,
109+
{
110+
done: true,
111+
result: translated,
112+
}
113+
);
114+
assert.dom(".ai-post-helper__suggestion__text").hasText(translated);
115+
});
116+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
export default [
2+
{
3+
id: -301,
4+
name: "translate",
5+
translated_name: "Translate to English (US)",
6+
prompt_type: "text",
7+
icon: "language",
8+
location: ["composer", "post"],
9+
},
10+
{
11+
id: -303,
12+
name: "proofread",
13+
translated_name: "Proofread text",
14+
prompt_type: "diff",
15+
icon: "spell-check",
16+
location: ["composer", "post"],
17+
},
18+
{
19+
id: -304,
20+
name: "markdown_table",
21+
translated_name: "Generate Markdown table",
22+
prompt_type: "diff",
23+
icon: "table",
24+
location: ["composer"],
25+
},
26+
{
27+
id: -305,
28+
name: "custom_prompt",
29+
translated_name: "Custom Prompt",
30+
prompt_type: "diff",
31+
icon: "comment",
32+
location: ["composer", "post"],
33+
},
34+
{
35+
id: -306,
36+
name: "explain",
37+
translated_name: "Explain",
38+
prompt_type: "text",
39+
icon: "question",
40+
location: ["post"],
41+
},
42+
{
43+
id: -307,
44+
name: "generate_titles",
45+
translated_name: "Suggest topic titles",
46+
prompt_type: "list",
47+
icon: "heading",
48+
location: ["composer"],
49+
},
50+
{
51+
id: -308,
52+
name: "illustrate_post",
53+
translated_name: "Illustrate Post",
54+
prompt_type: "list",
55+
icon: "images",
56+
location: ["composer"],
57+
},
58+
{
59+
id: -309,
60+
name: "detect_text_locale",
61+
translated_name: "detect_text_locale",
62+
prompt_type: "text",
63+
icon: null,
64+
location: [],
65+
},
66+
];

0 commit comments

Comments
 (0)