FEATURE: single click proofreading (#769)

Previously there was too much work proofreading text, new implementation
provides a single shortcut and easy way of proofreading text.


Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
Sam 2024-08-26 15:43:40 +10:00 committed by GitHub
parent c6aeabbfc0
commit f148452f4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 196 additions and 21 deletions

View File

@ -1,17 +1,82 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal"; import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
export default class ModalDiffModal extends Component { export default class ModalDiffModal extends Component {
@service currentUser;
@tracked loading = false;
@tracked diff;
suggestion = "";
PROOFREAD_ID = -303;
constructor() {
super(...arguments);
this.diff = this.args.model.diff;
next(() => {
if (this.args.model.toolbarEvent) {
this.loadDiff();
}
});
}
async loadDiff() {
this.loading = true;
try {
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: this.PROOFREAD_ID,
text: this.selectedText,
force_default_locale: true,
},
});
this.diff = suggestion.diff;
this.suggestion = suggestion.suggestions[0];
} catch (e) {
popupAjaxError(e);
} finally {
this.loading = false;
}
}
get selectedText() {
const selected = this.args.model.toolbarEvent.selected;
if (selected.value === "") {
return selected.pre + selected.post;
}
return selected.value;
}
@action @action
triggerConfirmChanges() { triggerConfirmChanges() {
this.args.closeModal(); this.args.closeModal();
if (this.args.model.confirm) {
this.args.model.confirm(); this.args.model.confirm();
} }
if (this.args.model.toolbarEvent && this.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.selectedText,
this.suggestion
);
}
}
@action @action
triggerRevertChanges() { triggerRevertChanges() {
this.args.model.revert(); this.args.model.revert();
@ -25,8 +90,13 @@ export default class ModalDiffModal extends Component {
@closeModal={{@closeModal}} @closeModal={{@closeModal}}
> >
<:body> <:body>
{{#if @model.diff}} {{#if this.loading}}
{{htmlSafe @model.diff}} <div class="composer-ai-helper-modal__loading">
<CookText @rawText={{this.selectedText}} />
</div>
{{else}}
{{#if this.diff}}
{{htmlSafe this.diff}}
{{else}} {{else}}
<div class="composer-ai-helper-modal__old-value"> <div class="composer-ai-helper-modal__old-value">
{{@model.oldValue}} {{@model.oldValue}}
@ -36,19 +106,30 @@ export default class ModalDiffModal extends Component {
{{@model.newValue}} {{@model.newValue}}
</div> </div>
{{/if}} {{/if}}
{{/if}}
</:body> </:body>
<:footer> <:footer>
{{#if this.loading}}
<DButton
class="btn-primary"
@label="discourse_ai.ai_helper.context_menu.loading"
@disabled={{true}}
/>
{{else}}
<DButton <DButton
class="btn-primary confirm" class="btn-primary confirm"
@action={{this.triggerConfirmChanges}} @action={{this.triggerConfirmChanges}}
@label="discourse_ai.ai_helper.context_menu.confirm" @label="discourse_ai.ai_helper.context_menu.confirm"
/> />
{{#if @model.revert}}
<DButton <DButton
class="btn-flat revert" class="btn-flat revert"
@action={{this.triggerRevertChanges}} @action={{this.triggerRevertChanges}}
@label="discourse_ai.ai_helper.context_menu.revert" @label="discourse_ai.ai_helper.context_menu.revert"
/> />
{{/if}}
{{/if}}
</:footer> </:footer>
</DModal> </DModal>
</template> </template>

View File

@ -0,0 +1,37 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import ModalDiffModal from "../discourse/components/modal/diff-modal";
function initializeProofread(api) {
api.addComposerToolbarPopupMenuOption({
action: (toolbarEvent) => {
const modal = api.container.lookup("service:modal");
modal.show(ModalDiffModal, {
model: {
toolbarEvent,
},
});
},
icon: "spell-check",
label: "discourse_ai.ai_helper.context_menu.proofread_prompt",
shortcut: "ALT+P",
condition: () => {
const siteSettings = api.container.lookup("service:site-settings");
const currentUser = api.getCurrentUser();
return (
siteSettings.ai_helper_enabled && currentUser?.can_use_assistant_in_post
);
},
});
}
export default {
name: "discourse-ai-helper",
initialize() {
withPluginApi("1.1.0", (api) => {
initializeProofread(api);
});
},
};

View File

@ -14,6 +14,18 @@
height: 200px; height: 200px;
} }
} }
@keyframes fadeOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
&__loading {
animation: fadeOpacity 1.5s infinite alternate;
}
&__old-value { &__old-value {
background-color: var(--danger-low); background-color: var(--danger-low);

View File

@ -294,12 +294,13 @@ en:
view_changes: "View Changes" view_changes: "View Changes"
confirm: "Confirm" confirm: "Confirm"
revert: "Revert" revert: "Revert"
changes: "Changes" changes: "Suggested Edits"
custom_prompt: custom_prompt:
title: "Custom Prompt" title: "Custom Prompt"
placeholder: "Enter a custom prompt..." placeholder: "Enter a custom prompt..."
submit: "Send Prompt" submit: "Send Prompt"
translate_prompt: "Translate to %{language}" translate_prompt: "Translate to %{language}"
proofread_prompt: "Proofread Text"
post_options_menu: post_options_menu:
trigger: "Ask AI" trigger: "Ask AI"
title: "Ask AI" title: "Ask AI"

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
before do
assign_fake_provider_to(:ai_helper_model)
SiteSetting.ai_helper_enabled = true
sign_in(admin)
end
let(:composer) { PageObjects::Components::Composer.new }
it "proofreads selected text using the composer toolbar" do
visit "/new-topic"
composer.fill_content("hello worldd !")
composer.select_range(6, 12)
DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options")
ai_toolbar.expand
ai_toolbar.select_row_by_name("Proofread Text")
find(".composer-ai-helper-modal .btn-primary.confirm").click
expect(composer.composer_input.value).to eq("hello world !")
end
end
it "proofreads all text when nothing is selected" do
visit "/new-topic"
composer.fill_content("hello worrld")
# Simulate AI response
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options")
ai_toolbar.expand
ai_toolbar.select_row_by_name("Proofread Text")
find(".composer-ai-helper-modal .btn-primary.confirm").click
expect(composer.composer_input.value).to eq("hello world")
end
end
end