Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Additional suggestion options #176

Merged
merged 11 commits into from Sep 2, 2023
45 changes: 45 additions & 0 deletions app/controllers/discourse_ai/ai_helper/assistant_controller.rb
Expand Up @@ -39,6 +39,51 @@ def suggest
status: 502
end

def suggest_title
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?

llm_prompt =
DiscourseAi::AiHelper::LlmPrompt
.new
.available_prompts(name_filter: "generate_titles")
.first
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?

RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!

hijack do
render json:
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
prompt,
params[:text],
),
status: 200
end
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

def suggest_category
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?

RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!

render json: DiscourseAi::AiHelper::SemanticCategorizer.new(params[:text]).categories,
status: 200
end

def suggest_tags
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?

RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!

render json: DiscourseAi::AiHelper::SemanticCategorizer.new(params[:text]).tags, status: 200
end

private

def ensure_can_request_suggestions
Expand Down
184 changes: 184 additions & 0 deletions assets/javascripts/discourse/components/ai-suggestion-dropdown.gjs
@@ -0,0 +1,184 @@
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 { popupAjaxError } from "discourse/lib/ajax-error";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { bind } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
import I18n from "I18n";

export default class AISuggestionDropdown extends Component {
<template>
<DButton
@class="suggestion-button {{if this.loading 'is-loading'}}"
@icon={{this.suggestIcon}}
@title="discourse_ai.ai_helper.suggest"
@action={{this.performSuggestion}}
@disabled={{this.disableSuggestionButton}}
...attributes
/>
{{#if this.showMenu}}
{{! template-lint-disable modifier-name-case }}
<ul class="popup-menu ai-suggestions-menu" {{didInsert this.handleClickOutside}}>
{{#if this.showErrors}}
<li class="ai-suggestions-menu__errors">{{this.error}}</li>
{{/if}}
{{#each this.generatedSuggestions as |suggestion index|}}
<li data-name={{suggestion}} data-value={{index}}>
<DButton
@class="popup-menu-btn"
@translatedLabel={{suggestion}}
@action={{this.applySuggestion}}
@actionParam={{suggestion}}
/>
</li>
{{/each}}
</ul>
{{/if}}
</template>

@service dialog;
@service site;
@service siteSettings;
@tracked loading = false;
@tracked showMenu = false;
@tracked generatedSuggestions = [];
@tracked suggestIcon = "discourse-sparkles";
@tracked showErrors = false;
@tracked error = "";
SUGGESTION_TYPES = {
title: "suggest_title",
category: "suggest_category",
tag: "suggest_tags",
};

willDestroy() {
super.willDestroy(...arguments);
document.removeEventListener("click", this.onClickOutside);
}

get composerInput() {
return document.querySelector(".d-editor-input")?.value || this.args.composer.reply;
}

get disableSuggestionButton() {
return this.loading;
}

closeMenu() {
this.suggestIcon = "discourse-sparkles";
this.showMenu = false;
this.showErrors = false;
this.errors = "";
}

@bind
onClickOutside(event) {
const menu = document.querySelector(".ai-title-suggestions-menu");

if (event.target === menu) {
return;
}

return this.closeMenu();
}

@action
handleClickOutside() {
document.addEventListener("click", this.onClickOutside);
}

@action
applySuggestion(suggestion) {
if (!this.args.mode) {
return;
}

const composer = this.args?.composer;
if (!composer) {
return;
}


if (this.args.mode === this.SUGGESTION_TYPES.title) {
composer.set("title", suggestion);
return this.closeMenu();
}

if (this.args.mode === this.SUGGESTION_TYPES.category) {
const selectedCategoryId = this.site.categories.find((c) => c.slug === suggestion).id;
composer.set("categoryId", selectedCategoryId);
return this.closeMenu();
}

if (this.args.mode === this.SUGGESTION_TYPES.tag) {
this.updateTags(suggestion, composer);
}
}

updateTags(suggestion, composer) {
const maxTags = this.siteSettings.max_tags_per_topic;

if (!composer.tags) {
composer.set("tags", [suggestion]);
// remove tag from the list of suggestions once added
this.generatedSuggestions = this.generatedSuggestions.filter((s) => s !== suggestion);
return;
}
const tags = composer.tags;

if (tags?.length >= maxTags) {
// Show error if trying to add more tags than allowed
this.showErrors = true;
this.error = I18n.t("select_kit.max_content_reached", { count: maxTags});
return;
}

tags.push(suggestion);
composer.set("tags", [...tags]);
// remove tag from the list of suggestions once added
return this.generatedSuggestions = this.generatedSuggestions.filter((s) => s !== suggestion);
}

@action
async performSuggestion() {
if (!this.args.mode) {
return;
}

if (this.composerInput?.length === 0) {
return this.dialog.alert(I18n.t("discourse_ai.ai_helper.missing_content"));
}

this.loading = true;
this.suggestIcon = "spinner";

return ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
method: "POST",
data: { text: this.composerInput },
}).then((data) => {
if (this.args.mode === this.SUGGESTION_TYPES.title) {
this.generatedSuggestions = data.suggestions;
} else {
const suggestions = data.assistant.map((s) => s.name);
if (this.SUGGESTION_TYPES.tag) {
if (this.args.composer?.tags && this.args.composer?.tags.length > 0) {
// Filter out tags if they are already selected in the tag input
this.generatedSuggestions = suggestions.filter((t) => !this.args.composer.tags.includes(t));
} else {
this.generatedSuggestions = suggestions;
}
} else {
this.generatedSuggestions = suggestions;
}
}
}).catch(popupAjaxError).finally(() => {
this.loading = false;
this.suggestIcon = "sync-alt";
this.showMenu = true;
});

}
}
@@ -0,0 +1,13 @@
import Component from '@glimmer/component';
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { inject as service } from "@ember/service";

export default class AICategorySuggestion extends Component {
<template>
{{#if this.siteSettings.ai_embeddings_enabled}}
<AISuggestionDropdown @mode="suggest_category" @composer={{@outletArgs.composer}} class="suggest-category-button"/>
{{/if}}
</template>

@service siteSettings;
}
@@ -0,0 +1,14 @@
import Component from '@glimmer/component';
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { inject as service } from "@ember/service";


export default class AITagSuggestion extends Component {
<template>
{{#if this.siteSettings.ai_embeddings_enabled}}
<AISuggestionDropdown @mode="suggest_tags" @composer={{@outletArgs.composer}} class="suggest-tags-button"/>
{{/if}}
</template>

@service siteSettings;
}

This file was deleted.

@@ -0,0 +1,8 @@
import Component from '@glimmer/component';
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";

export default class AITitleSuggestion extends Component {
<template>
<AISuggestionDropdown @mode="suggest_title" @composer={{@outletArgs.composer}} class="suggest-titles-button" />
</template>
}