FEATURE: AI helper on posts (#244)
Adds an AI Helper function when selecting text while viewing a topic. --------- Co-authored-by: Keegan George <kgeorge13@gmail.com> Co-authored-by: Roman Rizzi <roman@discourse.org>
This commit is contained in:
parent
cda5cb6e9c
commit
0e5764617a
|
@ -96,12 +96,37 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def explain
|
||||||
|
post_id = get_post_param!
|
||||||
|
text = get_text_param!
|
||||||
|
post = Post.find_by(id: post_id)
|
||||||
|
|
||||||
|
raise Discourse::InvalidParameters.new(:post_id) unless post
|
||||||
|
|
||||||
|
render json:
|
||||||
|
DiscourseAi::AiHelper::TopicHelper.new(
|
||||||
|
{ text: text },
|
||||||
|
current_user,
|
||||||
|
post: post,
|
||||||
|
).explain,
|
||||||
|
status: 200
|
||||||
|
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||||
|
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||||
|
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||||
|
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||||
|
status: 502
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def get_text_param!
|
def get_text_param!
|
||||||
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
|
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_post_param!
|
||||||
|
params[:post_id].tap { |t| raise Discourse::InvalidParameters.new(:post_id) if t.blank? }
|
||||||
|
end
|
||||||
|
|
||||||
def rate_limiter_performed!
|
def rate_limiter_performed!
|
||||||
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,6 +10,8 @@ class CompletionPrompt < ActiveRecord::Base
|
||||||
validate :each_message_length
|
validate :each_message_length
|
||||||
|
|
||||||
def messages_with_user_input(user_input)
|
def messages_with_user_input(user_input)
|
||||||
|
return messages unless user_input.present?
|
||||||
|
|
||||||
if user_input[:custom_prompt].present?
|
if user_input[:custom_prompt].present?
|
||||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||||
when "huggingface"
|
when "huggingface"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import showAIHelper from "../../lib/show-ai-helper";
|
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
|
|
||||||
export default class AICategorySuggestion extends Component {
|
export default class AICategorySuggestion extends Component {
|
||||||
|
@ -12,7 +12,7 @@ export default class AICategorySuggestion extends Component {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showAIHelper(outletArgs, helper);
|
return showComposerAIHelper(outletArgs, helper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import showAIHelper from "../../lib/show-ai-helper";
|
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
|
|
||||||
export default class AITagSuggestion extends Component {
|
export default class AITagSuggestion extends Component {
|
||||||
|
@ -12,7 +12,7 @@ export default class AITagSuggestion extends Component {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showAIHelper(outletArgs, helper);
|
return showComposerAIHelper(outletArgs, helper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
import showAIHelper from "../../lib/show-ai-helper";
|
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
export default class AITitleSuggestion extends Component {
|
export default class AITitleSuggestion extends Component {
|
||||||
<template>
|
<template>
|
||||||
|
@ -8,6 +8,6 @@ export default class AITitleSuggestion extends Component {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showAIHelper(outletArgs, helper);
|
return showComposerAIHelper(outletArgs, helper);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -8,11 +8,11 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import { createPopper } from "@popperjs/core";
|
import { createPopper } from "@popperjs/core";
|
||||||
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import showAIHelper from "../../lib/show-ai-helper";
|
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
export default class AiHelperContextMenu extends Component {
|
export default class AiHelperContextMenu extends Component {
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showAIHelper(outletArgs, helper);
|
return showComposerAIHelper(outletArgs, helper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
|
@ -79,7 +79,9 @@ export default class AiHelperContextMenu extends Component {
|
||||||
async loadPrompts() {
|
async loadPrompts() {
|
||||||
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||||
|
|
||||||
prompts = prompts.filter((p) => p.name !== "generate_titles");
|
prompts = prompts
|
||||||
|
.filter((p) => p.location.includes("composer"))
|
||||||
|
.filter((p) => p.name !== "generate_titles");
|
||||||
|
|
||||||
// Find the custom_prompt object and move it to the beginning of the array
|
// Find the custom_prompt object and move it to the beginning of the array
|
||||||
const customPromptIndex = prompts.findIndex(
|
const customPromptIndex = prompts.findIndex(
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { showPostAIHelper } from "../../lib/show-ai-helper";
|
||||||
|
import eq from "truth-helpers/helpers/eq";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
import I18n from "I18n";
|
||||||
|
|
||||||
|
const i18n = I18n.t.bind(I18n);
|
||||||
|
|
||||||
|
export default class AIHelperOptionsMenu extends Component {
|
||||||
|
<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"
|
||||||
|
@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|}}
|
||||||
|
<DButton
|
||||||
|
@class="btn-flat"
|
||||||
|
@icon={{option.icon}}
|
||||||
|
@translatedLabel={{option.name}}
|
||||||
|
@action={{this.performAISuggestion}}
|
||||||
|
@actionParam={{option}}
|
||||||
|
data-name={{option.name}}
|
||||||
|
data-value={{option.value}}
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{else if (eq this.menuState this.MENU_STATES.loading)}}
|
||||||
|
<div class="ai-helper-context-menu__loading">
|
||||||
|
<div class="dot-falling"></div>
|
||||||
|
<span>
|
||||||
|
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
|
||||||
|
</span>
|
||||||
|
<DButton
|
||||||
|
@icon="times"
|
||||||
|
@title="discourse_ai.ai_helper.context_menu.cancel"
|
||||||
|
@action={{this.cancelAIAction}}
|
||||||
|
class="btn-flat cancel-request"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{else if (eq this.menuState this.MENU_STATES.result)}}
|
||||||
|
<div class="ai-post-helper__suggestion">{{this.suggestion}}</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
static shouldRender(outletArgs, helper) {
|
||||||
|
return showPostAIHelper(outletArgs, helper);
|
||||||
|
}
|
||||||
|
@tracked helperOptions = [];
|
||||||
|
@tracked menuState = this.MENU_STATES.triggers;
|
||||||
|
@tracked loading = false;
|
||||||
|
@tracked suggestion = "";
|
||||||
|
@tracked showMainButtons = true;
|
||||||
|
|
||||||
|
MENU_STATES = {
|
||||||
|
triggers: "TRIGGERS",
|
||||||
|
options: "OPTIONS",
|
||||||
|
loading: "LOADING",
|
||||||
|
result: "RESULT"
|
||||||
|
};
|
||||||
|
|
||||||
|
@tracked _activeAIRequest = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
if (this.helperOptions.length === 0) {
|
||||||
|
this.loadPrompts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async showAIHelperOptions() {
|
||||||
|
this.showMainButtons = false;
|
||||||
|
this.menuState = this.MENU_STATES.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async performAISuggestion(option) {
|
||||||
|
this.menuState = this.MENU_STATES.loading;
|
||||||
|
|
||||||
|
if (option.name === "Explain") {
|
||||||
|
this._activeAIRequest = ajax("/discourse-ai/ai-helper/explain", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
mode: option.value,
|
||||||
|
text: this.args.outletArgs.data.quoteState.buffer,
|
||||||
|
post_id: this.args.outletArgs.data.quoteState.postId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
mode: option.value,
|
||||||
|
text: this.args.outletArgs.data.quoteState.buffer,
|
||||||
|
custom_prompt: "",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeAIRequest.then(({ suggestions }) => {
|
||||||
|
this.suggestion = suggestions[0];
|
||||||
|
}).catch(popupAjaxError).finally(() => {
|
||||||
|
this.loading = false;
|
||||||
|
this.menuState = this.MENU_STATES.result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this._activeAIRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
cancelAIAction() {
|
||||||
|
if (this._activeAIRequest) {
|
||||||
|
this._activeAIRequest.abort();
|
||||||
|
this._activeAIRequest = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.menuState = this.MENU_STATES.options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPrompts() {
|
||||||
|
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||||
|
|
||||||
|
this.helperOptions = prompts.filter(item => item.location.includes("post")).map((p) => {
|
||||||
|
return {
|
||||||
|
name: p.translated_name,
|
||||||
|
value: p.id,
|
||||||
|
icon: p.icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,38 @@
|
||||||
export default function showAIHelper(outletArgs, helper) {
|
export function showComposerAIHelper(outletArgs, helper) {
|
||||||
const helperEnabled =
|
const enableHelper = _helperEnabled(helper.siteSettings);
|
||||||
helper.siteSettings.discourse_ai_enabled &&
|
const enableAssistant = _canUseAssistant(
|
||||||
helper.siteSettings.composer_ai_helper_enabled;
|
helper.currentUser,
|
||||||
|
_findAllowedGroups(helper.siteSettings.ai_helper_allowed_groups)
|
||||||
const allowedGroups = helper.siteSettings.ai_helper_allowed_groups
|
|
||||||
.split("|")
|
|
||||||
.map((id) => parseInt(id, 10));
|
|
||||||
const canUseAssistant = helper.currentUser?.groups.some((g) =>
|
|
||||||
allowedGroups.includes(g.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm;
|
const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm;
|
||||||
|
|
||||||
if (outletArgs?.composer?.privateMessage) {
|
if (outletArgs?.composer?.privateMessage) {
|
||||||
return helperEnabled && canUseAssistant && canShowInPM;
|
return enableHelper && enableAssistant && canShowInPM;
|
||||||
}
|
}
|
||||||
|
|
||||||
return helperEnabled && canUseAssistant;
|
return enableHelper && enableAssistant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPostAIHelper(outletArgs, helper) {
|
||||||
|
return (
|
||||||
|
_helperEnabled(helper.siteSettings) &&
|
||||||
|
_canUseAssistant(
|
||||||
|
helper.currentUser,
|
||||||
|
_findAllowedGroups(helper.siteSettings.post_ai_helper_allowed_groups)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _helperEnabled(siteSettings) {
|
||||||
|
return (
|
||||||
|
siteSettings.discourse_ai_enabled && siteSettings.composer_ai_helper_enabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findAllowedGroups(setting) {
|
||||||
|
return setting.split("|").map((id) => parseInt(id, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _canUseAssistant(user, allowedGroups) {
|
||||||
|
return user?.groups.some((g) => allowedGroups.includes(g.id));
|
||||||
}
|
}
|
||||||
|
|
|
@ -319,3 +319,17 @@
|
||||||
transform: rotate(359deg);
|
transform: rotate(359deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-post-helper {
|
||||||
|
&__options {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__suggestion {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -66,6 +66,10 @@ en:
|
||||||
title: "Custom Prompt"
|
title: "Custom Prompt"
|
||||||
placeholder: "Enter a custom prompt..."
|
placeholder: "Enter a custom prompt..."
|
||||||
submit: "Send Prompt"
|
submit: "Send Prompt"
|
||||||
|
post_options_menu:
|
||||||
|
trigger: "Ask AI"
|
||||||
|
loading: "AI is generating"
|
||||||
|
close: "Close"
|
||||||
reviewables:
|
reviewables:
|
||||||
model_used: "Model used:"
|
model_used: "Model used:"
|
||||||
accuracy: "Accuracy:"
|
accuracy: "Accuracy:"
|
||||||
|
|
|
@ -108,6 +108,7 @@ en:
|
||||||
proofread: Proofread text
|
proofread: Proofread text
|
||||||
markdown_table: Generate Markdown table
|
markdown_table: Generate Markdown table
|
||||||
custom_prompt: "Custom Prompt"
|
custom_prompt: "Custom Prompt"
|
||||||
|
explain: "Explain"
|
||||||
|
|
||||||
ai_bot:
|
ai_bot:
|
||||||
personas:
|
personas:
|
||||||
|
|
|
@ -8,6 +8,7 @@ DiscourseAi::Engine.routes.draw do
|
||||||
post "suggest_category" => "assistant#suggest_category"
|
post "suggest_category" => "assistant#suggest_category"
|
||||||
post "suggest_tags" => "assistant#suggest_tags"
|
post "suggest_tags" => "assistant#suggest_tags"
|
||||||
post "suggest_thumbnails" => "assistant#suggest_thumbnails"
|
post "suggest_thumbnails" => "assistant#suggest_thumbnails"
|
||||||
|
post "explain" => "assistant#explain"
|
||||||
end
|
end
|
||||||
|
|
||||||
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do
|
||||||
|
|
|
@ -169,6 +169,13 @@ discourse_ai:
|
||||||
default: "3" # 3: @staff
|
default: "3" # 3: @staff
|
||||||
allow_any: false
|
allow_any: false
|
||||||
refresh: true
|
refresh: true
|
||||||
|
post_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_embeddings_enabled:
|
ai_embeddings_enabled:
|
||||||
default: false
|
default: false
|
||||||
|
|
|
@ -134,3 +134,25 @@ CompletionPrompt.seed do |cp|
|
||||||
you will {{custom_prompt}} and you will reply with the result.
|
you will {{custom_prompt}} and you will reply with the result.
|
||||||
TEXT
|
TEXT
|
||||||
end
|
end
|
||||||
|
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -6
|
||||||
|
cp.provider = "openai"
|
||||||
|
cp.name = "explain"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||||
|
cp.messages = [{ role: "Human", content: <<~TEXT }, { role: "Assistant", content: "" }]
|
||||||
|
You are a helpful assistant. Act as a tutor explaining terms to a student in a specific
|
||||||
|
context. Reply with a paragraph with a brief explanation about what the term means in the
|
||||||
|
content provided, format the response using markdown. Reply only with the explanation and
|
||||||
|
nothing more.
|
||||||
|
|
||||||
|
Term to explain:
|
||||||
|
{{search}}
|
||||||
|
|
||||||
|
Context where it was used:
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
Title of the conversation where it was used:
|
||||||
|
{{topic}}
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
|
@ -65,3 +65,29 @@ CompletionPrompt.seed do |cp|
|
||||||
you will {{custom_prompt}} and you will reply with the result between <ai></ai> tags.
|
you will {{custom_prompt}} and you will reply with the result between <ai></ai> tags.
|
||||||
TEXT
|
TEXT
|
||||||
end
|
end
|
||||||
|
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -106
|
||||||
|
cp.provider = "anthropic"
|
||||||
|
cp.name = "explain"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||||
|
cp.messages = [{ role: "Human", content: <<~TEXT }, { role: "Assistant", content: "" }]
|
||||||
|
You are a helpful assistant, I will provide you with a term inside <input> tags,
|
||||||
|
and the context where it was used inside <context> tags, the title of the topic
|
||||||
|
where it was used between <topic> tags, optionally the post it was written
|
||||||
|
in response to in <post> tags and you will reply with an explanation of what the
|
||||||
|
term means in this context between <ai></ai> tags.
|
||||||
|
|
||||||
|
<input>
|
||||||
|
{{search}}
|
||||||
|
</input>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
{{context}}
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<topic>
|
||||||
|
{{topic}}
|
||||||
|
</topic>
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
|
@ -126,3 +126,29 @@ CompletionPrompt.seed do |cp|
|
||||||
### Assistant:
|
### Assistant:
|
||||||
TEXT
|
TEXT
|
||||||
end
|
end
|
||||||
|
|
||||||
|
CompletionPrompt.seed do |cp|
|
||||||
|
cp.id = -206
|
||||||
|
cp.provider = "huggingface"
|
||||||
|
cp.name = "explain"
|
||||||
|
cp.prompt_type = CompletionPrompt.prompt_types[:text]
|
||||||
|
cp.messages = [<<~TEXT]
|
||||||
|
### System:
|
||||||
|
You are a helpful assistant. Act as a tutor explaining terms to a student in a specific
|
||||||
|
context. Reply with a paragraph with a brief explanation about what the term means in the
|
||||||
|
content provided, format the response using markdown. Reply only with the explanation and
|
||||||
|
nothing more.
|
||||||
|
|
||||||
|
### User:
|
||||||
|
Term to explain:
|
||||||
|
{{search}}
|
||||||
|
|
||||||
|
Context where it was used:
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
Title of the conversation where it was used:
|
||||||
|
{{topic}}
|
||||||
|
|
||||||
|
### Assistant:
|
||||||
|
TEXT
|
||||||
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ module DiscourseAi
|
||||||
require_relative "llm_prompt"
|
require_relative "llm_prompt"
|
||||||
require_relative "semantic_categorizer"
|
require_relative "semantic_categorizer"
|
||||||
require_relative "painter"
|
require_relative "painter"
|
||||||
|
require_relative "topic_helper"
|
||||||
end
|
end
|
||||||
|
|
||||||
def inject_into(plugin)
|
def inject_into(plugin)
|
||||||
|
|
|
@ -20,6 +20,7 @@ module DiscourseAi
|
||||||
translated_name: translation,
|
translated_name: translation,
|
||||||
prompt_type: prompt.prompt_type,
|
prompt_type: prompt.prompt_type,
|
||||||
icon: icon_map(prompt.name),
|
icon: icon_map(prompt.name),
|
||||||
|
location: location_map(prompt.name),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -64,11 +65,38 @@ module DiscourseAi
|
||||||
"comment"
|
"comment"
|
||||||
when "rewrite"
|
when "rewrite"
|
||||||
"pen"
|
"pen"
|
||||||
|
when "explain"
|
||||||
|
"question"
|
||||||
else
|
else
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def location_map(name)
|
||||||
|
case name
|
||||||
|
when "translate"
|
||||||
|
%w[composer post]
|
||||||
|
when "generate_titles"
|
||||||
|
%w[composer]
|
||||||
|
when "proofread"
|
||||||
|
%w[composer]
|
||||||
|
when "markdown_table"
|
||||||
|
%w[composer]
|
||||||
|
when "tone"
|
||||||
|
%w[composer]
|
||||||
|
when "custom_prompt"
|
||||||
|
%w[composer]
|
||||||
|
when "rewrite"
|
||||||
|
%w[composer]
|
||||||
|
when "explain"
|
||||||
|
%w[post]
|
||||||
|
when "summarize"
|
||||||
|
%w[post]
|
||||||
|
else
|
||||||
|
%w[composer post]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_diff(text, suggestion)
|
def generate_diff(text, suggestion)
|
||||||
cooked_text = PrettyText.cook(text)
|
cooked_text = PrettyText.cook(text)
|
||||||
cooked_suggestion = PrettyText.cook(suggestion)
|
cooked_suggestion = PrettyText.cook(suggestion)
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module AiHelper
|
||||||
|
class TopicHelper
|
||||||
|
def initialize(input, user, params = {})
|
||||||
|
@user = user
|
||||||
|
@text = input[:text]
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def explain
|
||||||
|
return nil if @text.blank?
|
||||||
|
return nil unless post = Post.find_by(id: @params[:post])
|
||||||
|
|
||||||
|
reply_to = post.topic.first_post
|
||||||
|
topic = reply_to.topic
|
||||||
|
|
||||||
|
llm_prompt =
|
||||||
|
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts(name_filter: "explain").first
|
||||||
|
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
|
||||||
|
|
||||||
|
prompt.messages.first["content"].gsub!("{{search}}", @text)
|
||||||
|
prompt.messages.first["content"].gsub!("{{context}}", post.raw)
|
||||||
|
prompt.messages.first["content"].gsub!("{{topic}}", topic.title)
|
||||||
|
# TODO inject this conditionally
|
||||||
|
#prompt.messages.first["content"].gsub!("{{post}}", reply_to.raw)
|
||||||
|
|
||||||
|
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,6 +5,7 @@ class OpenAiCompletionsInferenceStubs
|
||||||
PROOFREAD = "proofread"
|
PROOFREAD = "proofread"
|
||||||
GENERATE_TITLES = "generate_titles"
|
GENERATE_TITLES = "generate_titles"
|
||||||
CUSTOM_PROMPT = "custom_prompt"
|
CUSTOM_PROMPT = "custom_prompt"
|
||||||
|
EXPLAIN = "explain"
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def text_mode_to_id(mode)
|
def text_mode_to_id(mode)
|
||||||
|
@ -17,6 +18,8 @@ class OpenAiCompletionsInferenceStubs
|
||||||
-2
|
-2
|
||||||
when CUSTOM_PROMPT
|
when CUSTOM_PROMPT
|
||||||
-5
|
-5
|
||||||
|
when EXPLAIN
|
||||||
|
-4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -82,6 +85,15 @@ class OpenAiCompletionsInferenceStubs
|
||||||
STRING
|
STRING
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def explain_response
|
||||||
|
<<~STRING
|
||||||
|
"In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling.
|
||||||
|
The person states they enjoy eating pie, considering it a good dessert. They note that some people wastefully
|
||||||
|
throw pie at others, but the person themselves chooses to eat the pie rather than throwing it. Overall, \"pie\"
|
||||||
|
is being used to refer the the baked dessert food item."
|
||||||
|
STRING
|
||||||
|
end
|
||||||
|
|
||||||
def response(content)
|
def response(content)
|
||||||
{
|
{
|
||||||
id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S",
|
id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S",
|
||||||
|
@ -109,6 +121,8 @@ class OpenAiCompletionsInferenceStubs
|
||||||
generated_titles
|
generated_titles
|
||||||
when CUSTOM_PROMPT
|
when CUSTOM_PROMPT
|
||||||
custom_prompt_response
|
custom_prompt_response
|
||||||
|
when EXPLAIN
|
||||||
|
explain_response
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
# 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) }
|
||||||
|
fab!(:non_member_group) { Fabricate(:group) }
|
||||||
|
fab!(:topic) { Fabricate(:topic) }
|
||||||
|
fab!(:post) do
|
||||||
|
Fabricate(
|
||||||
|
:post,
|
||||||
|
topic: topic,
|
||||||
|
raw:
|
||||||
|
"I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
fab!(:post_2) do
|
||||||
|
Fabricate(:post, topic: topic, raw: OpenAiCompletionsInferenceStubs.spanish_text)
|
||||||
|
end
|
||||||
|
let(:topic_page) { PageObjects::Pages::Topic.new }
|
||||||
|
let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
|
||||||
|
SiteSetting.composer_ai_helper_enabled = true
|
||||||
|
sign_in(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_post_text(selected_post)
|
||||||
|
topic_page.visit_topic(topic)
|
||||||
|
page.execute_script(
|
||||||
|
"var element = document.querySelector('#{topic_page.post_by_number_selector(selected_post.post_number)} .cooked p'); " +
|
||||||
|
"var range = document.createRange(); " + "range.selectNodeContents(element); " +
|
||||||
|
"var selection = window.getSelection(); " + "selection.removeAllRanges(); " +
|
||||||
|
"selection.addRange(range);",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when triggering AI helper in post" do
|
||||||
|
it "shows the Ask AI button in the post selection toolbar" do
|
||||||
|
select_post_text(post)
|
||||||
|
expect(post_ai_helper).to have_post_selection_toolbar
|
||||||
|
expect(post_ai_helper).to have_post_ai_helper
|
||||||
|
end
|
||||||
|
|
||||||
|
it "shows AI helper options after clicking the AI button" do
|
||||||
|
select_post_text(post)
|
||||||
|
post_ai_helper.click_ai_button
|
||||||
|
expect(post_ai_helper).to have_no_post_selection_primary_buttons
|
||||||
|
expect(post_ai_helper).to have_post_ai_helper_options
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when using explain mode" do
|
||||||
|
skip "TODO: Fix explain mode option not appearing in spec" do
|
||||||
|
let(:mode) { OpenAiCompletionsInferenceStubs::EXPLAIN }
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "shows an explanation of the selected text" do
|
||||||
|
select_post_text(post)
|
||||||
|
post_ai_helper.click_ai_button
|
||||||
|
post_ai_helper.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
|
||||||
|
|
||||||
|
wait_for do
|
||||||
|
post_ai_helper.suggestion_value ==
|
||||||
|
OpenAiCompletionsInferenceStubs.explain_response.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(post_ai_helper.suggestion_value).to eq(
|
||||||
|
OpenAiCompletionsInferenceStubs.explain_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when using translate mode" do
|
||||||
|
skip "TODO: Fix WebMock request for translate mode not working" do
|
||||||
|
let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE }
|
||||||
|
before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) }
|
||||||
|
|
||||||
|
it "shows a translation of the selected text" do
|
||||||
|
select_post_text(post_2)
|
||||||
|
post_ai_helper.click_ai_button
|
||||||
|
post_ai_helper.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode))
|
||||||
|
|
||||||
|
wait_for do
|
||||||
|
post_ai_helper.suggestion_value ==
|
||||||
|
OpenAiCompletionsInferenceStubs.translated_response.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(post_ai_helper.suggestion_value).to eq(
|
||||||
|
OpenAiCompletionsInferenceStubs.translated_response.strip,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when AI helper is disabled" do
|
||||||
|
before { SiteSetting.composer_ai_helper_enabled = false }
|
||||||
|
|
||||||
|
it "does not show the Ask AI button in the post selection toolbar" do
|
||||||
|
select_post_text(post)
|
||||||
|
expect(post_ai_helper).to have_post_selection_toolbar
|
||||||
|
expect(post_ai_helper).to have_no_post_ai_helper
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when user is not a member of the post AI helper allowed group" do
|
||||||
|
before do
|
||||||
|
SiteSetting.composer_ai_helper_enabled = true
|
||||||
|
SiteSetting.post_ai_helper_allowed_groups = non_member_group.id.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not show the Ask AI button in the post selection toolbar" do
|
||||||
|
select_post_text(post)
|
||||||
|
expect(post_ai_helper).to have_post_selection_toolbar
|
||||||
|
expect(post_ai_helper).to have_no_post_ai_helper
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,64 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PageObjects
|
||||||
|
module Components
|
||||||
|
class AIHelperPostOptions < PageObjects::Components::Base
|
||||||
|
POST_SELECTION_TOOLBAR_SELECTOR = ".quote-button"
|
||||||
|
QUOTE_SELECTOR = ".insert-quote"
|
||||||
|
EDIT_SELECTOR = ".quote-edit-label"
|
||||||
|
SHARE_SELECTOR = ".quote-sharing"
|
||||||
|
|
||||||
|
AI_HELPER_SELECTOR = ".ai-post-helper"
|
||||||
|
TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger"
|
||||||
|
OPTIONS_SELECTOR = "#{AI_HELPER_SELECTOR}__options"
|
||||||
|
LOADING_SELECTOR = ".ai-helper-context-menu__loading"
|
||||||
|
SUGGESTION_SELECTOR = "#{AI_HELPER_SELECTOR}__suggestion"
|
||||||
|
|
||||||
|
def click_ai_button
|
||||||
|
find(TRIGGER_SELECTOR).click
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_helper_model(mode)
|
||||||
|
find("#{OPTIONS_SELECTOR} .btn[data-value=\"#{mode}\"]").click
|
||||||
|
end
|
||||||
|
|
||||||
|
def suggestion_value
|
||||||
|
find(SUGGESTION_SELECTOR).text
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_post_ai_helper?
|
||||||
|
page.has_css?(AI_HELPER_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_post_ai_helper?
|
||||||
|
page.has_no_css?(AI_HELPER_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_post_ai_helper_options?
|
||||||
|
page.has_css?(OPTIONS_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_post_ai_helper_options?
|
||||||
|
page.has_no_css?(OPTIONS_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_post_selection_toolbar?
|
||||||
|
page.has_css?(POST_SELECTION_TOOLBAR_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_post_selection_toolbar?
|
||||||
|
page.has_no_css?(POST_SELECTION_TOOLBAR_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_post_selection_primary_buttons?
|
||||||
|
page.has_css?(QUOTE_SELECTOR) || page.has_css?(EDIT_SELECTOR) ||
|
||||||
|
page.has_css?(SHARE_SELECTOR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_post_selection_primary_buttons?
|
||||||
|
page.has_no_css?(QUOTE_SELECTOR) || page.has_no_css?(EDIT_SELECTOR) ||
|
||||||
|
page.has_no_css?(SHARE_SELECTOR)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue