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]
def prompts
name_filter = params[:name_filter]
render json:
ActiveModel::ArraySerializer.new(
DiscourseAi::AiHelper::Assistant.new.available_prompts,
DiscourseAi::AiHelper::Assistant.new.available_prompts(name_filter: name_filter),
root: false,
),
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 { inject as service } from "@ember/service";
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 { popupAjaxError } from "discourse/lib/ajax-error";
import { cook } from "discourse/lib/text";
@ -20,6 +22,8 @@ export default class AIHelperOptionsMenu extends Component {
return showPostAIHelper(outletArgs, helper);
}
@service messageBus;
@service site;
@service modal;
@service siteSettings;
@service currentUser;
@tracked helperOptions = [];
@ -30,6 +34,8 @@ export default class AIHelperOptionsMenu extends Component {
@tracked customPromptValue = "";
@tracked copyButtonIcon = "copy";
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
@tracked showFastEdit = false;
@tracked showAiButtons = true;
MENU_STATES = {
triggers: "TRIGGERS",
@ -83,7 +89,7 @@ export default class AIHelperOptionsMenu extends Component {
async performAISuggestion(option) {
this.menuState = this.MENU_STATES.loading;
if (option.name === "Explain") {
if (option.name === "explain") {
this.menuState = this.MENU_STATES.result;
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
.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)
.finally(() => {
@ -166,6 +190,10 @@ export default class AIHelperOptionsMenu extends Component {
prompts = prompts.filter((p) => p.name !== "custom_prompt");
}
if (!this.args.outletArgs.data.canEditPost) {
prompts = prompts.filter((p) => p.name !== "proofread");
}
this.helperOptions = prompts;
}
@ -178,75 +206,95 @@ export default class AIHelperOptionsMenu extends Component {
return this.currentUser?.groups.some((g) => allowedGroups.includes(g.id));
}
@action
async closeFastEdit() {
this.showFastEdit = false;
await this.args.outletArgs.data.hideToolbar();
}
<template>
{{#if this.showMainButtons}}
{{yield}}
{{/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)}}
<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}}
/>
{{#if this.showAiButtons}}
<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)}}
<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}}
<DButton
@class="btn-flat"
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{this.performAISuggestion}}
@actionParam={{option}}
data-name={{option.name}}
data-value={{option.value}}
/>
<AiHelperLoading @cancel={{this.cancelAIAction}} />
{{/if}}
{{/each}}
</div>
</div>
{{/if}}
</div>
{{/if}}
{{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}}
<AiHelperLoading @cancel={{this.cancelAIAction}} />
{{/if}}
</div>
{{/if}}
</div>
{{#if this.showFastEdit}}
<div class="ai-post-helper__fast-edit">
<FastEdit
@initialValue={{@outletArgs.data.quoteState.buffer}}
@newValue={{this.suggestion}}
@post={{@outletArgs.post}}
@close={{this.closeFastEdit}}
/>
</div>
{{/if}}
</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"
copied: "Copied!"
cancel: "Cancel"
fast_edit:
suggest_button: "Suggest Edit"
reviewables:
model_used: "Model used:"
accuracy: "Accuracy:"

View File

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

View File

@ -108,4 +108,50 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do
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

View File

@ -15,8 +15,16 @@ RSpec.describe "AI Post helper", type: :system, js: true do
fab!(:post_2) do
Fabricate(:post, topic: topic, raw: "La lluvia en España se queda principalmente en el avión.")
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(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new }
let(:fast_editor) { PageObjects::Components::FastEditor.new }
before do
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." }
skip "TODO: Fix explain option stuck in loading in test" do
it "shows a translation of the selected text" do
select_post_text(post_2)
post_ai_helper.click_ai_button
it "shows a translation of the selected text" do
select_post_text(post_2)
post_ai_helper.click_ai_button
DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do
post_ai_helper.select_helper_model(mode)
DiscourseAi::Completions::Llm.with_prepared_responses([translated_input]) do
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)
end
expect(post_ai_helper.suggestion_value).to eq(translated_input)
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
@ -116,4 +140,21 @@ RSpec.describe "AI Post helper", type: :system, js: true do
expect(post_ai_helper).to have_no_post_ai_helper
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