FEATURE: Composer AI helper (#8)
* FEATURE: Composer AI helper This change introduces a new composer button for the group members listed in the `ai_helper_allowed_groups` site setting. Users can use chatGPT to review, improve, or translate their posts to English. * Add a safeguard for PMs and don't rely on parentView
This commit is contained in:
parent
aa2fca6086
commit
f99fe7e1ed
|
@ -0,0 +1,43 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module AiHelper
|
||||||
|
class AssistantController < ::ApplicationController
|
||||||
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
requires_login
|
||||||
|
before_action :ensure_can_request_suggestions
|
||||||
|
|
||||||
|
def suggest
|
||||||
|
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||||
|
|
||||||
|
if !DiscourseAi::AiHelper::OpenAiPrompt::VALID_TYPES.include?(params[:mode])
|
||||||
|
raise Discourse::InvalidParameters.new(:mode)
|
||||||
|
end
|
||||||
|
|
||||||
|
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||||
|
|
||||||
|
hijack do
|
||||||
|
render json:
|
||||||
|
DiscourseAi::AiHelper::OpenAiPrompt.new.generate_and_send_prompt(
|
||||||
|
params[:mode],
|
||||||
|
params[:text],
|
||||||
|
),
|
||||||
|
status: 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_can_request_suggestions
|
||||||
|
user_group_ids = current_user.group_ids
|
||||||
|
|
||||||
|
allowed =
|
||||||
|
SiteSetting.ai_helper_allowed_groups_map.any? do |group_id|
|
||||||
|
user_group_ids.include?(group_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
raise Discourse::InvalidAccess if !allowed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,67 @@
|
||||||
|
<DModalBody @title="discourse_ai.ai_helper.title">
|
||||||
|
<span>{{i18n "discourse_ai.ai_helper.description"}}</span>
|
||||||
|
|
||||||
|
<ComboBox
|
||||||
|
@value={{this.selected}}
|
||||||
|
@content={{this.helperOptions}}
|
||||||
|
@onChange={{action this.updateSelected}}
|
||||||
|
@valueProperty="value"
|
||||||
|
@class="ai-helper-mode"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-preview">
|
||||||
|
<Textarea
|
||||||
|
@value={{this.composedMessage}}
|
||||||
|
disabled="true"
|
||||||
|
class="preview-area"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selection-hint">{{i18n
|
||||||
|
"discourse_ai.ai_helper.selection_hint"
|
||||||
|
}}</div>
|
||||||
|
|
||||||
|
<div class="text-preview">
|
||||||
|
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
||||||
|
|
||||||
|
{{#unless this.loading}}
|
||||||
|
{{#if this.selectingTopicTitle}}
|
||||||
|
<div class="radios">
|
||||||
|
{{#each this.generatedTitlesSuggestions as |title index|}}
|
||||||
|
<label class="radio-label" for="title-suggestion-{{index}}">
|
||||||
|
<RadioButton
|
||||||
|
@id="title-suggestion-{{index}}"
|
||||||
|
@name="title-suggestion"
|
||||||
|
@value={{title}}
|
||||||
|
@selection={{this.selectedTitle}}
|
||||||
|
/>
|
||||||
|
<b>{{title}}</b>
|
||||||
|
</label>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{else if this.proofreadingText}}
|
||||||
|
{{html-safe this.proofreadDiff}}
|
||||||
|
{{else if this.translatingText}}
|
||||||
|
<Textarea
|
||||||
|
@value={{this.translatedSuggestion}}
|
||||||
|
disabled="true"
|
||||||
|
class="preview-area"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/unless}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</DModalBody>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
{{#if this.canSave}}
|
||||||
|
<DButton
|
||||||
|
@class="btn-primary create"
|
||||||
|
@action={{this.applySuggestion}}
|
||||||
|
@label="save"
|
||||||
|
/>
|
||||||
|
<DModalCancel @close={{route-action "closeModal"}} />
|
||||||
|
{{else}}
|
||||||
|
<div class="ai-helper-waiting-selection">Select an option...</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
|
@ -0,0 +1,128 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { action, computed } from "@ember/object";
|
||||||
|
import I18n from "I18n";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
|
||||||
|
const TRANSLATE = "translate";
|
||||||
|
const GENERATE_TITLES = "generate_titles";
|
||||||
|
const PROOFREAD = "proofread";
|
||||||
|
|
||||||
|
export default class AiHelper extends Component {
|
||||||
|
@tracked selected = null;
|
||||||
|
@tracked loading = false;
|
||||||
|
|
||||||
|
@tracked generatedTitlesSuggestions = [];
|
||||||
|
|
||||||
|
@tracked proofReadSuggestion = null;
|
||||||
|
@tracked translatedSuggestion = null;
|
||||||
|
@tracked selectedTitle = null;
|
||||||
|
|
||||||
|
@tracked proofreadDiff = null;
|
||||||
|
|
||||||
|
helperOptions = [
|
||||||
|
{
|
||||||
|
name: I18n.t("discourse_ai.ai_helper.modes.translate"),
|
||||||
|
value: TRANSLATE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: I18n.t("discourse_ai.ai_helper.modes.generate_titles"),
|
||||||
|
value: GENERATE_TITLES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: I18n.t("discourse_ai.ai_helper.modes.proofreader"),
|
||||||
|
value: PROOFREAD,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
get composedMessage() {
|
||||||
|
const editor = this.args.editor;
|
||||||
|
|
||||||
|
return editor.getSelected().value || editor.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed("selected", "selectedTitle", "translatingText", "proofreadingText")
|
||||||
|
get canSave() {
|
||||||
|
return (
|
||||||
|
(this.selected === GENERATE_TITLES && this.selectedTitle) ||
|
||||||
|
this.translatingText ||
|
||||||
|
this.proofreadingText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed("selected", "translatedSuggestion")
|
||||||
|
get translatingText() {
|
||||||
|
return this.selected === TRANSLATE && this.translatedSuggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed("selected", "proofReadSuggestion")
|
||||||
|
get proofreadingText() {
|
||||||
|
return this.selected === PROOFREAD && this.proofReadSuggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed("selected", "generatedTitlesSuggestions")
|
||||||
|
get selectingTopicTitle() {
|
||||||
|
return (
|
||||||
|
this.selected === GENERATE_TITLES &&
|
||||||
|
this.generatedTitlesSuggestions.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSuggestedByAI(value, data) {
|
||||||
|
switch (value) {
|
||||||
|
case GENERATE_TITLES:
|
||||||
|
this.generatedTitlesSuggestions = data.suggestions;
|
||||||
|
break;
|
||||||
|
case TRANSLATE:
|
||||||
|
this.translatedSuggestion = data.suggestions[0];
|
||||||
|
break;
|
||||||
|
case PROOFREAD:
|
||||||
|
this.proofReadSuggestion = data.suggestions[0];
|
||||||
|
this.proofreadDiff = data.diff;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async updateSelected(value) {
|
||||||
|
this.loading = true;
|
||||||
|
this.selected = value;
|
||||||
|
|
||||||
|
if (value === GENERATE_TITLES) {
|
||||||
|
this.selectedTitle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasSuggestion) {
|
||||||
|
this.loading = false;
|
||||||
|
} else {
|
||||||
|
return ajax("/discourse-ai/ai-helper/suggest", {
|
||||||
|
method: "POST",
|
||||||
|
data: { mode: this.selected, text: this.composedMessage },
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
this._updateSuggestedByAI(value, data);
|
||||||
|
})
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => (this.loading = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
applySuggestion() {
|
||||||
|
if (this.selectingTopicTitle) {
|
||||||
|
const composer = this.args.editor.outletArgs?.composer;
|
||||||
|
|
||||||
|
if (composer) {
|
||||||
|
composer.set("title", this.selectedTitle);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newText = this.proofreadingText
|
||||||
|
? this.proofReadSuggestion
|
||||||
|
: this.translatedSuggestion;
|
||||||
|
this.args.editor.replaceText(this.composedMessage, newText);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.args.closeModal();
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,9 +3,9 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{{#each-in @accuracies as |model acc|}}
|
{{#each-in @accuracies as |model acc|}}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4">{{i18n "discourse-ai.reviewables.model_used"}}</td>
|
<td colspan="4">{{i18n "discourse_ai.reviewables.model_used"}}</td>
|
||||||
<td colspan="3">{{model}}</td>
|
<td colspan="3">{{model}}</td>
|
||||||
<td colspan="4">{{i18n "discourse-ai.reviewables.accuracy"}}</td>
|
<td colspan="4">{{i18n "discourse_ai.reviewables.accuracy"}}</td>
|
||||||
<td colspan="3">{{acc}}%</td>
|
<td colspan="3">{{acc}}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each-in}}
|
{{/each-in}}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<AiHelper @editor={{this.editor}} @closeModal={{route-action "closeModal"}} />
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
import showModal from "discourse/lib/show-modal";
|
||||||
|
|
||||||
|
function initializeComposerAIHelper(api) {
|
||||||
|
api.modifyClass("component:composer-editor", {
|
||||||
|
actions: {
|
||||||
|
extraButtons(toolbar) {
|
||||||
|
this._super(toolbar);
|
||||||
|
|
||||||
|
const removeAiHelperFromPM =
|
||||||
|
this.composerModel.privateMessage &&
|
||||||
|
!this.siteSettings.ai_helper_allowed_in_pm;
|
||||||
|
|
||||||
|
if (removeAiHelperFromPM) {
|
||||||
|
const extrasGroup = toolbar.groups.find((g) => g.group === "extras");
|
||||||
|
const newButtons = extrasGroup.buttons.filter(
|
||||||
|
(b) => b.id !== "ai-helper"
|
||||||
|
);
|
||||||
|
|
||||||
|
extrasGroup.buttons = newButtons;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.modifyClass("component:d-editor", {
|
||||||
|
pluginId: "discourse-ai",
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
openAIHelper() {
|
||||||
|
if (this.value) {
|
||||||
|
showModal("composer-ai-helper").setProperties({ editor: this });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.onToolbarCreate((toolbar) => {
|
||||||
|
toolbar.addButton({
|
||||||
|
id: "ai-helper",
|
||||||
|
title: "discourse_ai.ai_helper.title",
|
||||||
|
group: "extras",
|
||||||
|
icon: "magic",
|
||||||
|
className: "composer-ai-helper",
|
||||||
|
sendAction: () => toolbar.context.send("openAIHelper"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "discourse_ai-composer-helper",
|
||||||
|
|
||||||
|
initialize(container) {
|
||||||
|
const settings = container.lookup("site-settings:main");
|
||||||
|
const user = container.lookup("service:current-user");
|
||||||
|
|
||||||
|
const helperEnabled =
|
||||||
|
settings.discourse_ai_enabled && settings.composer_ai_helper_enabled;
|
||||||
|
|
||||||
|
const allowedGroups = settings.ai_helper_allowed_groups
|
||||||
|
.split("|")
|
||||||
|
.map(parseInt);
|
||||||
|
const canUseAssistant =
|
||||||
|
user && user.groups.some((g) => allowedGroups.includes(g.id));
|
||||||
|
|
||||||
|
if (helperEnabled && canUseAssistant) {
|
||||||
|
withPluginApi("1.6.0", initializeComposerAIHelper);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,27 @@
|
||||||
|
.composer-ai-helper-modal {
|
||||||
|
.combobox,
|
||||||
|
.text-preview,
|
||||||
|
.ai-helper-waiting-selection {
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-preview {
|
||||||
|
ins {
|
||||||
|
background-color: var(--success-low);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
del {
|
||||||
|
background-color: var(--danger-low);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-hint {
|
||||||
|
font-size: var(--font-down-2);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,14 @@
|
||||||
en:
|
en:
|
||||||
js:
|
js:
|
||||||
discourse-ai:
|
discourse_ai:
|
||||||
|
ai_helper:
|
||||||
|
title: "Suggest changes using AI"
|
||||||
|
description: "Choose one of the options below, and the AI will suggest you a new version of the text."
|
||||||
|
selection_hint: "Hint: You can also select a portion of the text before opening the helper to rewrite only that."
|
||||||
|
modes:
|
||||||
|
translate: Translate to English
|
||||||
|
generate_titles: Suggest topic titles
|
||||||
|
proofreader: Proofread text
|
||||||
reviewables:
|
reviewables:
|
||||||
model_used: "Model used:"
|
model_used: "Model used:"
|
||||||
accuracy: "Accuracy:"
|
accuracy: "Accuracy:"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
en:
|
en:
|
||||||
site_settings:
|
site_settings:
|
||||||
ai_enabled: "Enable the discourse ai plugin."
|
discourse_ai_enabled: "Enable the discourse AI plugin."
|
||||||
ai_toxicity_enabled: "Enable the toxicity module."
|
ai_toxicity_enabled: "Enable the toxicity module."
|
||||||
ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module"
|
ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module"
|
||||||
ai_toxicity_inference_service_api_key: "API key for the toxicity API"
|
ai_toxicity_inference_service_api_key: "API key for the toxicity API"
|
||||||
|
@ -19,6 +19,23 @@ en:
|
||||||
ai_sentiment_inference_service_api_endpoint: "URL where the API is running for the sentiment module"
|
ai_sentiment_inference_service_api_endpoint: "URL where the API is running for the sentiment module"
|
||||||
ai_sentiment_inference_service_api_key: "API key for the sentiment API"
|
ai_sentiment_inference_service_api_key: "API key for the sentiment API"
|
||||||
ai_sentiment_models: "Models to use for inference. Sentiment classifies post on the positive/neutral/negative space. Emotion classifies on the anger/disgust/fear/joy/neutral/sadness/surprise space."
|
ai_sentiment_models: "Models to use for inference. Sentiment classifies post on the positive/neutral/negative space. Emotion classifies on the anger/disgust/fear/joy/neutral/sadness/surprise space."
|
||||||
|
|
||||||
|
ai_nsfw_detection_enabled: "Enable the NSFW module."
|
||||||
|
ai_nsfw_inference_service_api_endpoint: "URL where the API is running for the NSFW module"
|
||||||
|
ai_nsfw_inference_service_api_key: "API key for the NSFW API"
|
||||||
|
ai_nsfw_flag_automatically: "Automatically flag NSFW posts that are above the configured thresholds."
|
||||||
|
ai_nsfw_flag_threshold_general: "General Threshold for an image to be considered NSFW."
|
||||||
|
ai_nsfw_flag_threshold_drawings: "Threshold for a drawing to be considered NSFW."
|
||||||
|
ai_nsfw_flag_threshold_hentai: "Threshold for an image classified as hentai to be considered NSFW."
|
||||||
|
ai_nsfw_flag_threshold_porn: "Threshold for an image classified as porn to be considered NSFW."
|
||||||
|
ai_nsfw_flag_threshold_sexy: "Threshold for an image classified as sexy to be considered NSFW."
|
||||||
|
ai_nsfw_models: "Models to use for NSFW inference."
|
||||||
|
|
||||||
|
composer_ai_helper_enabled: "Enable the Composer's AI helper."
|
||||||
|
ai_openai_api_key: "API key for the AI helper"
|
||||||
|
ai_helper_allowed_groups: "Users on these groups will see the AI helper button in the composer."
|
||||||
|
ai_helper_allowed_in_pm: "Enable the composer's AI helper in PMs."
|
||||||
|
|
||||||
reviewables:
|
reviewables:
|
||||||
reasons:
|
reasons:
|
||||||
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
|
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
DiscourseAi::Engine.routes.draw do
|
||||||
|
# AI-helper routes
|
||||||
|
scope module: :ai_helper, path: "/ai-helper", defaults: { format: :json } do
|
||||||
|
post "suggest" => "assistant#suggest"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Discourse::Application.routes.append { mount ::DiscourseAi::Engine, at: "discourse-ai" }
|
|
@ -87,3 +87,17 @@ plugins:
|
||||||
|
|
||||||
ai_openai_api_key:
|
ai_openai_api_key:
|
||||||
default: ""
|
default: ""
|
||||||
|
|
||||||
|
composer_ai_helper_enabled:
|
||||||
|
default: false
|
||||||
|
client: true
|
||||||
|
ai_helper_allowed_groups:
|
||||||
|
client: true
|
||||||
|
type: group_list
|
||||||
|
list_type: compact
|
||||||
|
default: "3|14" # 3: @staff, 14: @trust_level_4
|
||||||
|
allow_any: false
|
||||||
|
refresh: true
|
||||||
|
ai_helper_allowed_in_pm:
|
||||||
|
default: false
|
||||||
|
client: true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module DiscourseAi
|
module ::DiscourseAi
|
||||||
class Engine < ::Rails::Engine
|
class Engine < ::Rails::Engine
|
||||||
engine_name PLUGIN_NAME
|
engine_name PLUGIN_NAME
|
||||||
isolate_namespace DiscourseAi
|
isolate_namespace DiscourseAi
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
module DiscourseAi
|
||||||
|
module AiHelper
|
||||||
|
class EntryPoint
|
||||||
|
def load_files
|
||||||
|
require_relative "open_ai_prompt"
|
||||||
|
end
|
||||||
|
|
||||||
|
def inject_into(plugin)
|
||||||
|
plugin.register_svg_icon("magic")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,92 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module AiHelper
|
||||||
|
class OpenAiPrompt
|
||||||
|
TRANSLATE = "translate"
|
||||||
|
GENERATE_TITLES = "generate_titles"
|
||||||
|
PROOFREAD = "proofread"
|
||||||
|
VALID_TYPES = [TRANSLATE, GENERATE_TITLES, PROOFREAD]
|
||||||
|
|
||||||
|
def get_prompt_for(prompt_type)
|
||||||
|
case prompt_type
|
||||||
|
when TRANSLATE
|
||||||
|
translate_prompt
|
||||||
|
when GENERATE_TITLES
|
||||||
|
generate_titles_prompt
|
||||||
|
when PROOFREAD
|
||||||
|
proofread_prompt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_and_send_prompt(prompt_type, text)
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
prompt = [
|
||||||
|
{ role: "system", content: get_prompt_for(prompt_type) },
|
||||||
|
{ role: "user", content: text },
|
||||||
|
]
|
||||||
|
|
||||||
|
result[:suggestions] = DiscourseAi::Inference::OpenAiCompletions
|
||||||
|
.perform!(prompt)
|
||||||
|
.dig(:choices)
|
||||||
|
.to_a
|
||||||
|
.flat_map { |choice| parse_content(prompt_type, choice.dig(:message, :content).to_s) }
|
||||||
|
.compact_blank
|
||||||
|
|
||||||
|
result[:diff] = generate_diff(text, result[:suggestions].first) if proofreading?(
|
||||||
|
prompt_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def proofreading?(prompt_type)
|
||||||
|
prompt_type == PROOFREAD
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_diff(text, suggestion)
|
||||||
|
cooked_text = PrettyText.cook(text)
|
||||||
|
cooked_suggestion = PrettyText.cook(suggestion)
|
||||||
|
|
||||||
|
DiscourseDiff.new(cooked_text, suggestion).inline_html
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_content(type, content)
|
||||||
|
return "" if content.blank?
|
||||||
|
return content.strip if type != GENERATE_TITLES
|
||||||
|
|
||||||
|
content.gsub("\"", "").gsub(/\d./, "").split("\n").map(&:strip)
|
||||||
|
end
|
||||||
|
|
||||||
|
def translate_prompt
|
||||||
|
<<~STRING
|
||||||
|
I want you to act as an English translator, spelling corrector and improver. I will speak to you
|
||||||
|
in any language and you will detect the language, translate it and answer in the corrected and
|
||||||
|
improved version of my text, in English. I want you to replace my simplified A0-level words and
|
||||||
|
sentences with more beautiful and elegant, upper level English words and sentences.
|
||||||
|
Keep the meaning same, but make them more literary. I want you to only reply the correction,
|
||||||
|
the improvements and nothing else, do not write explanations.
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_titles_prompt
|
||||||
|
<<~STRING
|
||||||
|
I want you to act as a title generator for written pieces. I will provide you with a text,
|
||||||
|
and you will generate five attention-grabbing titles. Please keep the title concise and under 20 words,
|
||||||
|
and ensure that the meaning is maintained. Replies will utilize the language type of the topic.
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
|
def proofread_prompt
|
||||||
|
<<~STRING
|
||||||
|
I want you act as a proofreader. I will provide you with a text and I want you to review them for any spelling,
|
||||||
|
grammar, or punctuation errors. Once you have finished reviewing the text, provide me with any necessary
|
||||||
|
corrections or suggestions for improve the text.
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,15 +2,13 @@
|
||||||
|
|
||||||
module ::DiscourseAi
|
module ::DiscourseAi
|
||||||
module Inference
|
module Inference
|
||||||
class OpenAICompletions
|
class OpenAiCompletions
|
||||||
def self.perform!(model, content, api_key)
|
def self.perform!(content, model = "gpt-3.5-turbo")
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
|
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
|
||||||
"Content-Type" => "application/json",
|
"Content-Type" => "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
model ||= "gpt-3.5-turbo"
|
|
||||||
|
|
||||||
response =
|
response =
|
||||||
Faraday.post(
|
Faraday.post(
|
||||||
"https://api.openai.com/v1/chat/completions",
|
"https://api.openai.com/v1/chat/completions",
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
module ::DiscourseAi
|
module ::DiscourseAi
|
||||||
module Inference
|
module Inference
|
||||||
class OpenAIEmbeddings
|
class OpenAiEmbeddings
|
||||||
def self.perform!(content, model = nil)
|
def self.perform!(content, model = nil)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
|
"Authorization" => "Bearer #{SiteSetting.ai_openai_api_key}",
|
||||||
|
|
|
@ -9,6 +9,8 @@
|
||||||
|
|
||||||
enabled_site_setting :discourse_ai_enabled
|
enabled_site_setting :discourse_ai_enabled
|
||||||
|
|
||||||
|
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||||
|
|
||||||
module ::DiscourseAi
|
module ::DiscourseAi
|
||||||
PLUGIN_NAME = "discourse-ai"
|
PLUGIN_NAME = "discourse-ai"
|
||||||
end
|
end
|
||||||
|
@ -28,11 +30,13 @@ after_initialize do
|
||||||
require_relative "lib/modules/nsfw/entry_point"
|
require_relative "lib/modules/nsfw/entry_point"
|
||||||
require_relative "lib/modules/toxicity/entry_point"
|
require_relative "lib/modules/toxicity/entry_point"
|
||||||
require_relative "lib/modules/sentiment/entry_point"
|
require_relative "lib/modules/sentiment/entry_point"
|
||||||
|
require_relative "lib/modules/ai_helper/entry_point"
|
||||||
|
|
||||||
[
|
[
|
||||||
DiscourseAi::NSFW::EntryPoint.new,
|
DiscourseAi::NSFW::EntryPoint.new,
|
||||||
DiscourseAi::Toxicity::EntryPoint.new,
|
DiscourseAi::Toxicity::EntryPoint.new,
|
||||||
DiscourseAi::Sentiment::EntryPoint.new,
|
DiscourseAi::Sentiment::EntryPoint.new,
|
||||||
|
DiscourseAi::AiHelper::EntryPoint.new,
|
||||||
].each do |a_module|
|
].each do |a_module|
|
||||||
a_module.load_files
|
a_module.load_files
|
||||||
a_module.inject_into(self)
|
a_module.inject_into(self)
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "../../../support/openai_completions_inference_stubs"
|
||||||
|
|
||||||
|
RSpec.describe DiscourseAi::AiHelper::OpenAiPrompt do
|
||||||
|
describe "#generate_and_send_prompt" do
|
||||||
|
context "when using the translate mode" do
|
||||||
|
let(:mode) { described_class::TRANSLATE }
|
||||||
|
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "Sends the prompt to chatGPT and returns the response" do
|
||||||
|
response =
|
||||||
|
subject.generate_and_send_prompt(mode, OpenAiCompletionsInferenceStubs.spanish_text)
|
||||||
|
|
||||||
|
expect(response[:suggestions]).to contain_exactly(
|
||||||
|
OpenAiCompletionsInferenceStubs.translated_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when using the proofread mode" do
|
||||||
|
let(:mode) { described_class::PROOFREAD }
|
||||||
|
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "Sends the prompt to chatGPT and returns the response" do
|
||||||
|
response =
|
||||||
|
subject.generate_and_send_prompt(
|
||||||
|
mode,
|
||||||
|
OpenAiCompletionsInferenceStubs.translated_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response[:suggestions]).to contain_exactly(
|
||||||
|
OpenAiCompletionsInferenceStubs.proofread_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when generating titles" do
|
||||||
|
let(:mode) { described_class::GENERATE_TITLES }
|
||||||
|
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "returns an array with each title" do
|
||||||
|
expected =
|
||||||
|
OpenAiCompletionsInferenceStubs
|
||||||
|
.generated_titles
|
||||||
|
.gsub("\"", "")
|
||||||
|
.gsub(/\d./, "")
|
||||||
|
.split("\n")
|
||||||
|
.map(&:strip)
|
||||||
|
|
||||||
|
response =
|
||||||
|
subject.generate_and_send_prompt(
|
||||||
|
mode,
|
||||||
|
OpenAiCompletionsInferenceStubs.translated_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response[:suggestions]).to contain_exactly(*expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,68 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "../../support/openai_completions_inference_stubs"
|
||||||
|
|
||||||
|
RSpec.describe DiscourseAi::AiHelper::AssistantController do
|
||||||
|
describe "#suggest" do
|
||||||
|
let(:text) { OpenAiCompletionsInferenceStubs.translated_response }
|
||||||
|
let(:mode) { DiscourseAi::AiHelper::OpenAiPrompt::PROOFREAD }
|
||||||
|
|
||||||
|
context "when not logged in" do
|
||||||
|
it "returns a 403 response" do
|
||||||
|
post "/discourse-ai/ai-helper/suggest", params: { text: text, mode: mode }
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when logged in as an 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
|
||||||
|
post "/discourse-ai/ai-helper/suggest", params: { text: text, mode: mode }
|
||||||
|
|
||||||
|
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 400 if the helper mode is invalid" do
|
||||||
|
invalid_mode = "asd"
|
||||||
|
|
||||||
|
post "/discourse-ai/ai-helper/suggest", params: { text: text, mode: invalid_mode }
|
||||||
|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a 400 if the text is blank" do
|
||||||
|
post "/discourse-ai/ai-helper/suggest", params: { mode: mode }
|
||||||
|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a suggestion" do
|
||||||
|
OpenAiCompletionsInferenceStubs.stub_prompt(mode)
|
||||||
|
|
||||||
|
post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text }
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["suggestions"].first).to eq(
|
||||||
|
OpenAiCompletionsInferenceStubs.proofread_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,96 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class OpenAiCompletionsInferenceStubs
|
||||||
|
class << self
|
||||||
|
def spanish_text
|
||||||
|
<<~STRING
|
||||||
|
Para que su horror sea perfecto, César, acosado al pie de la estatua por lo impacientes puñales de sus amigos,
|
||||||
|
descubre entre las caras y los aceros la de Marco Bruto, su protegido, acaso su hijo,
|
||||||
|
y ya no se defiende y exclama: ¡Tú también, hijo mío! Shakespeare y Quevedo recogen el patético grito.
|
||||||
|
|
||||||
|
Al destino le agradan las repeticiones, las variantes, las simetrías; diecinueve siglos después,
|
||||||
|
en el sur de la provincia de Buenos Aires, un gaucho es agredido por otros gauchos y, al caer,
|
||||||
|
reconoce a un ahijado suyo y le dice con mansa reconvención y lenta sorpresa (estas palabras hay que oírlas, no leerlas):
|
||||||
|
¡Pero, che! Lo matan y no sabe que muere para que se repita una escena.
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
|
def translated_response
|
||||||
|
<<~STRING
|
||||||
|
"To perfect his horror, Caesar, surrounded at the base of the statue by the impatient daggers of his friends,
|
||||||
|
discovers among the faces and blades that of Marcus Brutus, his protege, perhaps his son, and he no longer
|
||||||
|
defends himself, but instead exclaims: 'You too, my son!' Shakespeare and Quevedo capture the pathetic cry.
|
||||||
|
|
||||||
|
Destiny favors repetitions, variants, symmetries; nineteen centuries later, in the southern province of Buenos Aires,
|
||||||
|
a gaucho is attacked by other gauchos and, as he falls, recognizes a godson of his and says with gentle rebuke and
|
||||||
|
slow surprise (these words must be heard, not read): 'But, my friend!' He is killed and does not know that he
|
||||||
|
dies so that a scene may be repeated."
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
|
def generated_titles
|
||||||
|
<<~STRING
|
||||||
|
1. "The Life and Death of a Nameless Gaucho"
|
||||||
|
2. "The Faith of Iron and Courage: A Gaucho's Legacy"
|
||||||
|
3. "The Quiet Piece that Moves Literature: A Gaucho's Story"
|
||||||
|
4. "The Unknown Hero: A Gaucho's Sacrifice for Country"
|
||||||
|
5. "From Dust to Legacy: The Enduring Name of a Gaucho"
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
|
def proofread_response
|
||||||
|
<<~STRING
|
||||||
|
"This excerpt explores the idea of repetition and symmetry in tragic events. The author highlights two instances
|
||||||
|
where someone is betrayed by a close friend or protege, uttering a similar phrase of surprise and disappointment
|
||||||
|
before their untimely death. The first example refers to Julius Caesar, who upon realizing that one of his own
|
||||||
|
friends and proteges, Marcus Brutus, is among his assassins, exclaims \"You too, my son!\" The second example
|
||||||
|
is of a gaucho in Buenos Aires, who recognizes his godson among his attackers and utters the words of rebuke
|
||||||
|
and surprise, \"But, my friend!\" before he is killed. The author suggests that these tragedies occur so that
|
||||||
|
a scene may be repeated, emphasizing the cyclical nature of history and the inevitability of certain events."
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
|
def response(content)
|
||||||
|
{
|
||||||
|
id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S",
|
||||||
|
object: "chat.completion",
|
||||||
|
created: 1_678_464_820,
|
||||||
|
model: "gpt-3.5-turbo-0301",
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 337,
|
||||||
|
completion_tokens: 162,
|
||||||
|
total_tokens: 499,
|
||||||
|
},
|
||||||
|
choices: [
|
||||||
|
{ message: { role: "assistant", content: content }, finish_reason: "stop", index: 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_text_for(type)
|
||||||
|
case type
|
||||||
|
when DiscourseAi::AiHelper::OpenAiPrompt::TRANSLATE
|
||||||
|
translated_response
|
||||||
|
when DiscourseAi::AiHelper::OpenAiPrompt::PROOFREAD
|
||||||
|
proofread_response
|
||||||
|
when DiscourseAi::AiHelper::OpenAiPrompt::GENERATE_TITLES
|
||||||
|
generated_titles
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stub_prompt(type)
|
||||||
|
prompt_builder = DiscourseAi::AiHelper::OpenAiPrompt.new
|
||||||
|
text =
|
||||||
|
type == DiscourseAi::AiHelper::OpenAiPrompt::TRANSLATE ? spanish_text : translated_response
|
||||||
|
prompt = [
|
||||||
|
{ role: "system", content: prompt_builder.get_prompt_for(type) },
|
||||||
|
{ role: "user", content: text },
|
||||||
|
]
|
||||||
|
|
||||||
|
WebMock
|
||||||
|
.stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
||||||
|
.with(body: JSON.dump(model: "gpt-3.5-turbo", messages: prompt))
|
||||||
|
.to_return(status: 200, body: JSON.dump(response(response_text_for(type))))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,86 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "../../support/openai_completions_inference_stubs"
|
||||||
|
|
||||||
|
RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
|
fab!(:user) { Fabricate(:admin) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
|
||||||
|
SiteSetting.composer_ai_helper_enabled = true
|
||||||
|
sign_in(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:composer) { PageObjects::Components::Composer.new }
|
||||||
|
let(:ai_helper_modal) { PageObjects::Modals::AiHelper.new }
|
||||||
|
|
||||||
|
context "when using the translation mode" do
|
||||||
|
let(:mode) { DiscourseAi::AiHelper::OpenAiPrompt::TRANSLATE }
|
||||||
|
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "replaces the composed message with AI generated content" do
|
||||||
|
visit("/latest")
|
||||||
|
page.find("#create-topic").click
|
||||||
|
|
||||||
|
composer.fill_content(OpenAiCompletionsInferenceStubs.spanish_text)
|
||||||
|
page.find(".composer-ai-helper").click
|
||||||
|
|
||||||
|
expect(ai_helper_modal).to be_visible
|
||||||
|
|
||||||
|
ai_helper_modal.select_helper_model(mode)
|
||||||
|
ai_helper_modal.save_changes
|
||||||
|
|
||||||
|
expect(composer.composer_input.value).to eq(
|
||||||
|
OpenAiCompletionsInferenceStubs.translated_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when using the proofreading mode" do
|
||||||
|
let(:mode) { DiscourseAi::AiHelper::OpenAiPrompt::PROOFREAD }
|
||||||
|
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "replaces the composed message with AI generated content" do
|
||||||
|
visit("/latest")
|
||||||
|
page.find("#create-topic").click
|
||||||
|
|
||||||
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
|
page.find(".composer-ai-helper").click
|
||||||
|
|
||||||
|
expect(ai_helper_modal).to be_visible
|
||||||
|
|
||||||
|
ai_helper_modal.select_helper_model(mode)
|
||||||
|
ai_helper_modal.save_changes
|
||||||
|
|
||||||
|
expect(composer.composer_input.value).to eq(
|
||||||
|
OpenAiCompletionsInferenceStubs.proofread_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when selecting an AI generated title" do
|
||||||
|
let(:mode) { DiscourseAi::AiHelper::OpenAiPrompt::GENERATE_TITLES }
|
||||||
|
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "replaces the topic title" do
|
||||||
|
visit("/latest")
|
||||||
|
page.find("#create-topic").click
|
||||||
|
|
||||||
|
composer.fill_content(OpenAiCompletionsInferenceStubs.translated_response)
|
||||||
|
page.find(".composer-ai-helper").click
|
||||||
|
|
||||||
|
expect(ai_helper_modal).to be_visible
|
||||||
|
|
||||||
|
ai_helper_modal.select_helper_model(mode)
|
||||||
|
ai_helper_modal.select_title_suggestion(2)
|
||||||
|
ai_helper_modal.save_changes
|
||||||
|
|
||||||
|
expected_title = "The Quiet Piece that Moves Literature: A Gaucho's Story"
|
||||||
|
|
||||||
|
expect(find("#reply-title").value).to eq(expected_title)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PageObjects
|
||||||
|
module Modals
|
||||||
|
class AiHelper < PageObjects::Modals::Base
|
||||||
|
def visible?
|
||||||
|
page.has_css?(".composer-ai-helper-modal")
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_helper_model(mode)
|
||||||
|
find(".ai-helper-mode").click
|
||||||
|
find(".select-kit-row[data-value=\"#{mode}\"]").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_changes
|
||||||
|
find(".modal-footer button.create").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_title_suggestion(option_number)
|
||||||
|
find("input#title-suggestion-#{option_number}").click
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue