FEATURE: Proofread with post AI helper (#359)

This commit is contained in:
Keegan George 2023-12-14 19:30:52 -08:00 committed by GitHub
parent 74a7ac4a3d
commit 408d9f68eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 301 additions and 76 deletions

View File

@ -9,9 +9,11 @@ module DiscourseAi
before_action :rate_limiter_performed!, except: %i[prompts] before_action :rate_limiter_performed!, except: %i[prompts]
def prompts def prompts
name_filter = params[:name_filter]
render json: render json:
ActiveModel::ArraySerializer.new( ActiveModel::ArraySerializer.new(
DiscourseAi::AiHelper::Assistant.new.available_prompts, DiscourseAi::AiHelper::Assistant.new.available_prompts(name_filter: name_filter),
root: false, root: false,
), ),
status: 200 status: 200

View File

@ -0,0 +1,80 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { showPostAIHelper } from "../../lib/show-ai-helper";
export default class AiEditSuggestionButton extends Component {
static shouldRender(outletArgs, helper) {
return showPostAIHelper(outletArgs, helper);
}
@tracked loading = false;
@tracked suggestion = "";
@tracked _activeAIRequest = null;
constructor() {
super(...arguments);
if (!this.mode) {
this.loadMode();
}
}
get disabled() {
return (
this.loading ||
this.suggestion?.length > 0 ||
this.args.outletArgs.newValue
);
}
async loadMode() {
let mode = await ajax("/discourse-ai/ai-helper/prompts", {
method: "GET",
data: {
name_filter: "proofread",
},
});
this.mode = mode[0];
}
@action
suggest() {
this.loading = true;
this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: this.mode.id,
text: this.args.outletArgs.initialValue,
custom_prompt: "",
},
});
this._activeAIRequest
.then(({ suggestions }) => {
this.suggestion = suggestions[0].trim();
this.args.outletArgs.updateValue(this.suggestion);
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
});
return this._activeAIRequest;
}
<template>
<DButton
class="btn-small btn-ai-suggest-edit"
@action={{this.suggest}}
@icon="discourse-sparkles"
@label="discourse_ai.ai_helper.fast_edit.suggest_button"
@isLoading={{this.loading}}
@disabled={{this.disabled}}
/>
</template>
}

View File

@ -5,6 +5,8 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button"; 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 { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { cook } from "discourse/lib/text"; import { cook } from "discourse/lib/text";
@ -20,6 +22,8 @@ export default class AIHelperOptionsMenu extends Component {
return showPostAIHelper(outletArgs, helper); return showPostAIHelper(outletArgs, helper);
} }
@service messageBus; @service messageBus;
@service site;
@service modal;
@service siteSettings; @service siteSettings;
@service currentUser; @service currentUser;
@tracked helperOptions = []; @tracked helperOptions = [];
@ -30,6 +34,8 @@ export default class AIHelperOptionsMenu extends Component {
@tracked customPromptValue = ""; @tracked customPromptValue = "";
@tracked copyButtonIcon = "copy"; @tracked copyButtonIcon = "copy";
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy"; @tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
@tracked showFastEdit = false;
@tracked showAiButtons = true;
MENU_STATES = { MENU_STATES = {
triggers: "TRIGGERS", triggers: "TRIGGERS",
@ -83,7 +89,7 @@ export default class AIHelperOptionsMenu extends Component {
async performAISuggestion(option) { async performAISuggestion(option) {
this.menuState = this.MENU_STATES.loading; this.menuState = this.MENU_STATES.loading;
if (option.name === "Explain") { if (option.name === "explain") {
this.menuState = this.MENU_STATES.result; this.menuState = this.MENU_STATES.result;
const fetchUrl = `/discourse-ai/ai-helper/explain`; const fetchUrl = `/discourse-ai/ai-helper/explain`;
@ -106,10 +112,28 @@ export default class AIHelperOptionsMenu extends Component {
}); });
} }
if (option.name !== "Explain") { if (option.name !== "explain") {
this._activeAIRequest this._activeAIRequest
.then(({ suggestions }) => { .then(({ suggestions }) => {
this.suggestion = suggestions[0]; this.suggestion = suggestions[0].trim();
if (option.name === "proofread") {
this.showAiButtons = false;
if (this.site.desktopView) {
this.showFastEdit = true;
return;
} else {
return this.modal.show(FastEditModal, {
model: {
initialValue: this.args.outletArgs.data.quoteState.buffer,
newValue: this.suggestion,
post: this.args.outletArgs.post,
close: this.closeFastEdit,
},
});
}
}
}) })
.catch(popupAjaxError) .catch(popupAjaxError)
.finally(() => { .finally(() => {
@ -166,6 +190,10 @@ export default class AIHelperOptionsMenu extends Component {
prompts = prompts.filter((p) => p.name !== "custom_prompt"); prompts = prompts.filter((p) => p.name !== "custom_prompt");
} }
if (!this.args.outletArgs.data.canEditPost) {
prompts = prompts.filter((p) => p.name !== "proofread");
}
this.helperOptions = prompts; this.helperOptions = prompts;
} }
@ -178,75 +206,95 @@ export default class AIHelperOptionsMenu extends Component {
return this.currentUser?.groups.some((g) => allowedGroups.includes(g.id)); return this.currentUser?.groups.some((g) => allowedGroups.includes(g.id));
} }
@action
async closeFastEdit() {
this.showFastEdit = false;
await this.args.outletArgs.data.hideToolbar();
}
<template> <template>
{{#if this.showMainButtons}} {{#if this.showMainButtons}}
{{yield}} {{yield}}
{{/if}} {{/if}}
<div class="ai-post-helper">
{{#if (eq this.menuState this.MENU_STATES.triggers)}}
<DButton
@class="btn-flat ai-post-helper__trigger"
@icon="discourse-sparkles"
@title="discourse_ai.ai_helper.post_options_menu.title"
@label="discourse_ai.ai_helper.post_options_menu.trigger"
@action={{this.showAIHelperOptions}}
/>
{{else if (eq this.menuState this.MENU_STATES.options)}} {{#if this.showAiButtons}}
<div class="ai-post-helper__options"> <div class="ai-post-helper">
{{#each this.helperOptions as |option|}} {{#if (eq this.menuState this.MENU_STATES.triggers)}}
{{#if (eq option.name "custom_prompt")}} <DButton
<AiHelperCustomPrompt @class="btn-flat ai-post-helper__trigger"
@value={{this.customPromptValue}} @icon="discourse-sparkles"
@promptArgs={{option}} @title="discourse_ai.ai_helper.post_options_menu.title"
@submit={{this.performAISuggestion}} @label="discourse_ai.ai_helper.post_options_menu.trigger"
/> @action={{this.showAIHelperOptions}}
/>
{{else if (eq this.menuState this.MENU_STATES.options)}}
<div class="ai-post-helper__options">
{{#each this.helperOptions as |option|}}
{{#if (eq option.name "custom_prompt")}}
<AiHelperCustomPrompt
@value={{this.customPromptValue}}
@promptArgs={{option}}
@submit={{this.performAISuggestion}}
/>
{{else}}
<DButton
@class="btn-flat"
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{this.performAISuggestion}}
@actionParam={{option}}
data-name={{option.name}}
data-value={{option.id}}
/>
{{/if}}
{{/each}}
</div>
{{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="ai-post-helper__suggestion__text">
{{this.suggestion}}
</div>
<di class="ai-post-helper__suggestion__buttons">
<DButton
@class="btn-flat ai-post-helper__suggestion__cancel"
@icon="times"
@label="discourse_ai.ai_helper.post_options_menu.cancel"
@action={{this.cancelAIAction}}
/>
<DButton
@class="btn-flat ai-post-helper__suggestion__copy"
@icon={{this.copyButtonIcon}}
@label={{this.copyButtonLabel}}
@action={{this.copySuggestion}}
@disabled={{not this.suggestion}}
/>
</di>
{{else}} {{else}}
<DButton <AiHelperLoading @cancel={{this.cancelAIAction}} />
@class="btn-flat"
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{this.performAISuggestion}}
@actionParam={{option}}
data-name={{option.name}}
data-value={{option.value}}
/>
{{/if}} {{/if}}
{{/each}} </div>
</div> {{/if}}
</div>
{{/if}}
{{else if (eq this.menuState this.MENU_STATES.loading)}} {{#if this.showFastEdit}}
<AiHelperLoading @cancel={{this.cancelAIAction}} /> <div class="ai-post-helper__fast-edit">
{{else if (eq this.menuState this.MENU_STATES.result)}} <FastEdit
<div @initialValue={{@outletArgs.data.quoteState.buffer}}
class="ai-post-helper__suggestion" @newValue={{this.suggestion}}
{{didInsert this.subscribe}} @post={{@outletArgs.post}}
{{willDestroy this.unsubscribe}} @close={{this.closeFastEdit}}
> />
{{#if this.suggestion}} </div>
<div class="ai-post-helper__suggestion__text"> {{/if}}
{{this.suggestion}}
</div>
<di class="ai-post-helper__suggestion__buttons">
<DButton
@class="btn-flat ai-post-helper__suggestion__cancel"
@icon="times"
@label="discourse_ai.ai_helper.post_options_menu.cancel"
@action={{this.cancelAIAction}}
/>
<DButton
@class="btn-flat ai-post-helper__suggestion__copy"
@icon={{this.copyButtonIcon}}
@label={{this.copyButtonLabel}}
@action={{this.copySuggestion}}
@disabled={{not this.suggestion}}
/>
</di>
{{else}}
<AiHelperLoading @cancel={{this.cancelAIAction}} />
{{/if}}
</div>
{{/if}}
</div>
</template> </template>
} }

View File

@ -343,4 +343,10 @@
} }
} }
} }
&__fast-edit {
.fast-edit-container {
padding-top: 0.5em;
}
}
} }

View File

@ -104,6 +104,8 @@ en:
copy: "Copy" copy: "Copy"
copied: "Copied!" copied: "Copied!"
cancel: "Cancel" cancel: "Cancel"
fast_edit:
suggest_button: "Suggest Edit"
reviewables: reviewables:
model_used: "Model used:" model_used: "Model used:"
accuracy: "Accuracy:" accuracy: "Accuracy:"

View File

@ -126,7 +126,7 @@ module DiscourseAi
when "generate_titles" when "generate_titles"
%w[composer] %w[composer]
when "proofread" when "proofread"
%w[composer] %w[composer post]
when "markdown_table" when "markdown_table"
%w[composer] %w[composer]
when "tone" when "tone"

View File

@ -108,4 +108,50 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do
end end
end end
end end
describe "#prompts" do
context "when not logged in" do
it "returns a 403 response" do
get "/discourse-ai/ai-helper/prompts"
expect(response.status).to eq(403)
end
end
context "when logged in as a user without enough privileges" do
fab!(:user) { Fabricate(:newuser) }
before do
sign_in(user)
SiteSetting.ai_helper_allowed_groups = Group::AUTO_GROUPS[:staff]
end
it "returns a 403 response" do
get "/discourse-ai/ai-helper/prompts"
expect(response.status).to eq(403)
end
end
context "when logged in as an allowed user" do
fab!(:user) { Fabricate(:user) }
before do
sign_in(user)
user.group_ids = [Group::AUTO_GROUPS[:trust_level_1]]
SiteSetting.ai_helper_allowed_groups = Group::AUTO_GROUPS[:trust_level_1]
end
it "returns a list of prompts when no name_filter is provided" do
get "/discourse-ai/ai-helper/prompts"
expect(response.status).to eq(200)
expect(response.parsed_body.length).to eq(6)
end
it "returns a list with with filtered prompts when name_filter is provided" do
get "/discourse-ai/ai-helper/prompts", params: { name_filter: "proofread" }
expect(response.status).to eq(200)
expect(response.parsed_body.length).to eq(1)
expect(response.parsed_body.first["name"]).to eq("proofread")
end
end
end
end end

View File

@ -15,8 +15,16 @@ RSpec.describe "AI Post helper", type: :system, js: true do
fab!(:post_2) do fab!(:post_2) do
Fabricate(:post, topic: topic, raw: "La lluvia en España se queda principalmente en el avión.") Fabricate(:post, topic: topic, raw: "La lluvia en España se queda principalmente en el avión.")
end end
fab!(:post_3) do
Fabricate(
:post,
topic: topic,
raw: "The Toyota Supra delivrs 382 horsepwr makin it a very farst car.",
)
end
let(:topic_page) { PageObjects::Pages::Topic.new } let(:topic_page) { PageObjects::Pages::Topic.new }
let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new } let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new }
let(:fast_editor) { PageObjects::Components::FastEditor.new }
before do before do
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
@ -77,18 +85,34 @@ RSpec.describe "AI Post helper", type: :system, js: true do
let(:translated_input) { "The rain in Spain, stays mainly in the Plane." } let(:translated_input) { "The rain in Spain, stays mainly in the Plane." }
skip "TODO: Fix explain option stuck in loading in test" do it "shows a translation of the selected text" do
it "shows a translation of the selected text" do select_post_text(post_2)
select_post_text(post_2) post_ai_helper.click_ai_button
post_ai_helper.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do
post_ai_helper.select_helper_model(mode) post_ai_helper.select_helper_model(mode)
wait_for { post_ai_helper.suggestion_value == translated_input } wait_for { post_ai_helper.suggestion_value == translated_input }
expect(post_ai_helper.suggestion_value).to eq(translated_input) expect(post_ai_helper.suggestion_value).to eq(translated_input)
end end
end
end
context "when using proofread mode" do
let(:mode) { CompletionPrompt::PROOFREAD }
let(:proofread_response) do
"The Toyota Supra delivers 382 horsepower making it a very fast car."
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)
end end
end end
end end
@ -116,4 +140,21 @@ RSpec.describe "AI Post helper", type: :system, js: true do
expect(post_ai_helper).to have_no_post_ai_helper expect(post_ai_helper).to have_no_post_ai_helper
end end
end end
context "when triggering AI proofread through edit button" do
let(:proofread_response) do
"The Toyota Supra delivers 382 horsepower making it a very fast car."
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)
end
end
end
end end