Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f54e2709 |
@@ -1,5 +1,5 @@
|
||||
# **Discourse AI** Plugin
|
||||
# **Plugin Name** Plugin
|
||||
|
||||
**Plugin Summary**
|
||||
|
||||
For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco
|
||||
For more information, please see: **url to meta topic**
|
||||
|
||||
@@ -6,7 +6,6 @@ module DiscourseAi
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
before_action :ensure_can_request_suggestions
|
||||
before_action :rate_limiter_performed!, except: %i[prompts]
|
||||
|
||||
def prompts
|
||||
render json:
|
||||
@@ -18,94 +17,29 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def suggest
|
||||
input = get_text_param!
|
||||
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||
|
||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
||||
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
if prompt.prompt_type == "custom_prompt" && params[:custom_prompt].blank?
|
||||
raise Discourse::InvalidParameters.new(:custom_prompt)
|
||||
end
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, params),
|
||||
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_title
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
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,
|
||||
input_hash,
|
||||
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
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
render json:
|
||||
DiscourseAi::AiHelper::SemanticCategorizer.new(
|
||||
input_hash,
|
||||
current_user,
|
||||
).categories,
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest_tags
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(input_hash, current_user).tags,
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest_thumbnails
|
||||
input = get_text_param!
|
||||
|
||||
hijack do
|
||||
thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user)
|
||||
|
||||
render json: { thumbnails: thumbnails }, status: 200
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_text_param!
|
||||
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
|
||||
end
|
||||
|
||||
def rate_limiter_performed!
|
||||
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||
end
|
||||
|
||||
def ensure_can_request_suggestions
|
||||
user_group_ids = current_user.group_ids
|
||||
|
||||
|
||||
@@ -8,11 +8,8 @@ module DiscourseAi
|
||||
SEMANTIC_SEARCH_TYPE = "semantic_search"
|
||||
|
||||
def search
|
||||
query = params[:q].to_s
|
||||
|
||||
if query.length < SiteSetting.min_search_term_length
|
||||
raise Discourse::InvalidParameters.new(:q)
|
||||
end
|
||||
query = params[:q]
|
||||
page = (params[:page] || 1).to_i
|
||||
|
||||
grouped_results =
|
||||
Search::GroupedSearchResults.new(
|
||||
@@ -22,19 +19,12 @@ module DiscourseAi
|
||||
use_pg_headlines_for_excerpt: false,
|
||||
)
|
||||
|
||||
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
|
||||
DiscourseAi::Embeddings::SemanticSearch
|
||||
.new(guardian)
|
||||
.search_for_topics(query, page)
|
||||
.each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
if !semantic_search.cached_query?(query)
|
||||
RateLimiter.new(current_user, "semantic-search", 4, 1.minutes).performed!
|
||||
end
|
||||
|
||||
hijack do
|
||||
semantic_search
|
||||
.search_for_topics(query)
|
||||
.each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
end
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,24 +10,10 @@ class CompletionPrompt < ActiveRecord::Base
|
||||
validate :each_message_length
|
||||
|
||||
def messages_with_user_input(user_input)
|
||||
if user_input[:custom_prompt].present?
|
||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||
when "huggingface"
|
||||
self.messages.each { |msg| msg.sub!("{{custom_prompt}}", user_input[:custom_prompt]) }
|
||||
else
|
||||
self.messages.each do |msg|
|
||||
msg["content"].sub!("{{custom_prompt}}", user_input[:custom_prompt])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||
when "openai"
|
||||
self.messages << { role: "user", content: user_input[:text] }
|
||||
when "anthropic"
|
||||
self.messages << { "role" => "Input", "content" => "<input>#{user_input[:text]}</input>" }
|
||||
when "huggingface"
|
||||
self.messages.first.sub("{{user_input}}", user_input[:text])
|
||||
if ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider == "openai"
|
||||
self.messages << { role: "user", content: user_input }
|
||||
else
|
||||
self.messages << { "role" => "Input", "content" => "<input>#{user_input}</input>" }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<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">{{i18n
|
||||
"discourse_ai.modals.select_option"
|
||||
}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action, computed } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
|
||||
const LIST = "list";
|
||||
const TEXT = "text";
|
||||
const DIFF = "diff";
|
||||
|
||||
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;
|
||||
|
||||
@tracked helperOptions = [];
|
||||
prompts = [];
|
||||
promptTypes = {};
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.loadPrompts();
|
||||
}
|
||||
|
||||
async loadPrompts() {
|
||||
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||
|
||||
prompts.map((p) => {
|
||||
this.prompts[p.id] = p;
|
||||
});
|
||||
|
||||
this.promptTypes = prompts.reduce((memo, p) => {
|
||||
memo[p.name] = p.prompt_type;
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
this.helperOptions = prompts.map((p) => {
|
||||
return {
|
||||
name: p.translated_name,
|
||||
value: p.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get composedMessage() {
|
||||
const editor = this.args.editor;
|
||||
|
||||
return editor.getSelected().value || editor.value;
|
||||
}
|
||||
|
||||
@computed("selected", "selectedTitle", "translatingText", "proofreadingText")
|
||||
get canSave() {
|
||||
return (
|
||||
(this.selected &&
|
||||
this.prompts[this.selected].prompt_type === LIST &&
|
||||
this.selectedTitle) ||
|
||||
this.translatingText ||
|
||||
this.proofreadingText
|
||||
);
|
||||
}
|
||||
|
||||
@computed("selected", "translatedSuggestion")
|
||||
get translatingText() {
|
||||
return (
|
||||
this.selected &&
|
||||
this.prompts[this.selected].prompt_type === TEXT &&
|
||||
this.translatedSuggestion
|
||||
);
|
||||
}
|
||||
|
||||
@computed("selected", "proofReadSuggestion")
|
||||
get proofreadingText() {
|
||||
return (
|
||||
this.selected &&
|
||||
this.prompts[this.selected].prompt_type === DIFF &&
|
||||
this.proofReadSuggestion
|
||||
);
|
||||
}
|
||||
|
||||
@computed("selected", "generatedTitlesSuggestions")
|
||||
get selectingTopicTitle() {
|
||||
return (
|
||||
this.selected &&
|
||||
this.prompts[this.selected].prompt_type === LIST &&
|
||||
this.generatedTitlesSuggestions.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
_updateSuggestedByAI(data) {
|
||||
switch (data.type) {
|
||||
case LIST:
|
||||
this.generatedTitlesSuggestions = data.suggestions;
|
||||
break;
|
||||
case TEXT:
|
||||
this.translatedSuggestion = data.suggestions[0];
|
||||
break;
|
||||
case DIFF:
|
||||
this.proofReadSuggestion = data.suggestions[0];
|
||||
this.proofreadDiff = data.diff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async updateSelected(value) {
|
||||
this.loading = true;
|
||||
this.selected = value;
|
||||
|
||||
if (value === LIST) {
|
||||
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(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();
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
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";
|
||||
import DButton from "discourse/components/d-button";
|
||||
|
||||
export default class AISuggestionDropdown extends Component {
|
||||
<template>
|
||||
{{#if this.showAIButton}}
|
||||
<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}}
|
||||
|
||||
{{#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 siteSettings;
|
||||
@service composer;
|
||||
@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 showAIButton() {
|
||||
const minCharacterCount = 40;
|
||||
return this.composer.model.replyLength > minCharacterCount;
|
||||
}
|
||||
|
||||
get disableSuggestionButton() {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
@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.composer.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);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async performSuggestion() {
|
||||
if (!this.args.mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.composer.model.replyLength === 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.composer.model.reply },
|
||||
}).then((data) => {
|
||||
this.#assignGeneratedSuggestions(data, this.args.mode);
|
||||
}).catch(popupAjaxError).finally(() => {
|
||||
this.loading = false;
|
||||
this.suggestIcon = "sync-alt";
|
||||
this.showMenu = true;
|
||||
});
|
||||
}
|
||||
|
||||
#closeMenu() {
|
||||
this.suggestIcon = "discourse-sparkles";
|
||||
this.showMenu = false;
|
||||
this.showErrors = false;
|
||||
this.errors = "";
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
#tagSelectorHasValues() {
|
||||
return this.args.composer?.tags && this.args.composer?.tags.length > 0;
|
||||
}
|
||||
|
||||
#assignGeneratedSuggestions(data, mode) {
|
||||
if (mode === this.SUGGESTION_TYPES.title) {
|
||||
return this.generatedSuggestions = data.suggestions;
|
||||
}
|
||||
|
||||
const suggestions = data.assistant.map((s) => s.name);
|
||||
|
||||
if (mode === this.SUGGESTION_TYPES.tag) {
|
||||
if (this.#tagSelectorHasValues()) {
|
||||
// Filter out tags if they are already selected in the tag input
|
||||
return this.generatedSuggestions = suggestions.filter((t) => !this.args.composer.tags.includes(t));
|
||||
} else {
|
||||
return this.generatedSuggestions = suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
return this.generatedSuggestions = suggestions;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export default class ModalDiffModal extends Component {
|
||||
</:footer>
|
||||
</DModal>
|
||||
</template>
|
||||
|
||||
|
||||
@action
|
||||
triggerConfirmChanges() {
|
||||
this.args.closeModal();
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import { inject as service } from "@ember/service";
|
||||
import showAIHelper from "../../lib/show-ai-helper";
|
||||
|
||||
|
||||
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>
|
||||
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showAIHelper(outletArgs, helper);
|
||||
}
|
||||
|
||||
@service siteSettings;
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import { inject as service } from "@ember/service";
|
||||
import showAIHelper from "../../lib/show-ai-helper";
|
||||
|
||||
|
||||
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>
|
||||
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showAIHelper(outletArgs, helper);
|
||||
}
|
||||
|
||||
@service siteSettings;
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import showAIHelper from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AITitleSuggestion extends Component {
|
||||
<template>
|
||||
<AISuggestionDropdown @mode="suggest_title" @composer={{@outletArgs.composer}} class="suggest-titles-button" />
|
||||
</template>
|
||||
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showAIHelper(outletArgs, helper);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<ul class="ai-helper-context-menu__trigger">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="discourse-sparkles"
|
||||
@icon="magic"
|
||||
@action={{this.toggleAiHelperOptions}}
|
||||
@label="discourse_ai.ai_helper.context_menu.trigger"
|
||||
class="btn-flat"
|
||||
@@ -16,53 +16,40 @@
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
|
||||
<ul class="ai-helper-context-menu__options">
|
||||
{{#each this.helperOptions as |option|}}
|
||||
{{#if (eq option.name "custom_prompt")}}
|
||||
<div
|
||||
class="ai-custom-prompt"
|
||||
{{did-insert this.setupCustomPrompt}}
|
||||
>
|
||||
<Input
|
||||
@value={{this.customPromptValue}}
|
||||
placeholder="Enter a custom prompt..."
|
||||
class="ai-custom-prompt__input"
|
||||
@enter={{action (fn this.updateSelected option)}}
|
||||
/>
|
||||
<li data-name={{option.name}} data-value={{option.value}}>
|
||||
<DButton
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{option.name}}
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option.value}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
<DButton
|
||||
@class="ai-custom-prompt__submit btn-primary"
|
||||
@icon="discourse-sparkles"
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option}}
|
||||
@disabled={{not this.customPromptValue.length}}
|
||||
/>
|
||||
</div>
|
||||
{{else}}
|
||||
<li data-name={{option.translated_name}} data-value={{option.id}}>
|
||||
<DButton
|
||||
@icon={{option.icon}}
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{option.translated_name}}
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.suggestions)}}
|
||||
<ul class="ai-helper-context-menu__suggestions">
|
||||
{{#each this.generatedTitleSuggestions as |suggestion index|}}
|
||||
<li data-name={{suggestion}} data-value={{index}}>
|
||||
<DButton
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{suggestion}}
|
||||
@action={{this.updateTopicTitle}}
|
||||
@actionParam={{suggestion}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_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>
|
||||
<ul class="ai-helper-context-menu__loading">
|
||||
<li>
|
||||
<div class="dot-falling"></div>
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.review)}}
|
||||
<ul class="ai-helper-context-menu__review">
|
||||
@@ -112,6 +99,7 @@
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ import { inject as service } from "@ember/service";
|
||||
import { computed } from "@ember/object";
|
||||
|
||||
export default class extends Component {
|
||||
static shouldRender() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<div class="gpt-persona">
|
||||
<DropdownSelectBox
|
||||
@value={{this.value}}
|
||||
@content={{this.botOptions}}
|
||||
@options={{hash icon="robot"}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
function isBotMessage(composer, currentUser) {
|
||||
if (
|
||||
composer &&
|
||||
composer.targetRecipients &&
|
||||
currentUser.ai_enabled_chat_bots
|
||||
) {
|
||||
const reciepients = composer.targetRecipients.split(",");
|
||||
|
||||
return currentUser.ai_enabled_chat_bots.any((bot) =>
|
||||
reciepients.any((username) => username === bot.username)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default class BotSelector extends Component {
|
||||
static shouldRender(args, container) {
|
||||
return (
|
||||
container?.currentUser?.ai_enabled_personas &&
|
||||
isBotMessage(args.model, container.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
@service currentUser;
|
||||
|
||||
get composer() {
|
||||
return this.args?.outletArgs?.model;
|
||||
}
|
||||
|
||||
get botOptions() {
|
||||
if (this.currentUser.ai_enabled_personas) {
|
||||
return this.currentUser.ai_enabled_personas.map((persona) => {
|
||||
return {
|
||||
id: persona.name,
|
||||
name: persona.name,
|
||||
description: persona.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value || this.botOptions[0].id;
|
||||
}
|
||||
|
||||
set value(val) {
|
||||
this._value = val;
|
||||
this.composer.metaData = { ai_persona: val };
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
{{#if this.searchEnabled}}
|
||||
<div class="semantic-search__container search-results" role="region">
|
||||
<div
|
||||
class="semantic-search__results"
|
||||
{{did-insert this.setup}}
|
||||
{{did-insert this.debouncedSearch}}
|
||||
{{will-destroy this.teardown}}
|
||||
>
|
||||
{{#if this.searching}}
|
||||
<div class="semantic-search__searching">
|
||||
<div class="semantic-search__searching-text">
|
||||
{{i18n "discourse_ai.embeddings.semantic_search_loading"}}
|
||||
</div>
|
||||
<span class="semantic-search__indicator-wave">
|
||||
<span class="semantic-search__indicator-dot">.</span>
|
||||
<span class="semantic-search__indicator-dot">.</span>
|
||||
<span class="semantic-search__indicator-dot">.</span>
|
||||
</span>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if this.results.length}}
|
||||
<div class="semantic-search__toggle-button-container">
|
||||
<DButton
|
||||
@translatedTitle={{this.collapsedResultsTitle}}
|
||||
@translatedLabel={{this.collapsedResultsTitle}}
|
||||
@action={{fn
|
||||
(mut this.collapsedResults)
|
||||
(not this.collapsedResults)
|
||||
}}
|
||||
@class="btn-flat"
|
||||
@icon={{if this.collapsedResults "chevron-right" "chevron-down"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#unless this.collapsedResults}}
|
||||
<div class="semantic-search__entries">
|
||||
<SearchResultEntries
|
||||
@posts={{this.results}}
|
||||
@highlightQuery={{this.highlightQuery}}
|
||||
/>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{else}}
|
||||
<div class="semantic-search__searching">
|
||||
{{i18n "discourse_ai.embeddings.semantic_search_results.none"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action, computed } from "@ember/object";
|
||||
import I18n from "I18n";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { isValidSearchTerm, translateResults } from "discourse/lib/search";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
|
||||
|
||||
export default class extends Component {
|
||||
static shouldRender(_args, { siteSettings }) {
|
||||
return siteSettings.ai_embeddings_semantic_search_enabled;
|
||||
}
|
||||
|
||||
@service appEvents;
|
||||
@service siteSettings;
|
||||
|
||||
@tracked searching = true;
|
||||
@tracked collapsedResults = true;
|
||||
@tracked results = [];
|
||||
|
||||
@computed("args.outletArgs.search")
|
||||
get searchTerm() {
|
||||
return this.args.outletArgs.search;
|
||||
}
|
||||
|
||||
@computed("args.outletArgs.type", "searchTerm")
|
||||
get searchEnabled() {
|
||||
return (
|
||||
this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
|
||||
isValidSearchTerm(this.searchTerm, this.siteSettings)
|
||||
);
|
||||
}
|
||||
|
||||
@computed("results")
|
||||
get collapsedResultsTitle() {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_results.toggle", {
|
||||
count: this.results.length,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
setup() {
|
||||
this.appEvents.on(
|
||||
"full-page-search:trigger-search",
|
||||
this,
|
||||
"debouncedSearch"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.appEvents.off(
|
||||
"full-page-search:trigger-search",
|
||||
this,
|
||||
"debouncedSearch"
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
performHyDESearch() {
|
||||
if (!this.searchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.searching = true;
|
||||
this.collapsedResults = true;
|
||||
this.results = [];
|
||||
|
||||
ajax("/discourse-ai/embeddings/semantic-search", {
|
||||
data: { q: this.searchTerm },
|
||||
})
|
||||
.then(async (results) => {
|
||||
const model = (await translateResults(results)) || {};
|
||||
this.results = model.posts;
|
||||
})
|
||||
.finally(() => (this.searching = false));
|
||||
}
|
||||
|
||||
@action
|
||||
debouncedSearch() {
|
||||
discourseDebounce(this, this.performHyDESearch, 500);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export default function showAIHelper(outletArgs, helper) {
|
||||
const helperEnabled =
|
||||
helper.siteSettings.discourse_ai_enabled &&
|
||||
helper.siteSettings.composer_ai_helper_enabled;
|
||||
|
||||
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;
|
||||
|
||||
if (outletArgs?.composer?.privateMessage) {
|
||||
return helperEnabled && canUseAssistant && canShowInPM;
|
||||
}
|
||||
|
||||
return helperEnabled && canUseAssistant;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<AiHelper @editor={{this.editor}} @closeModal={{route-action "closeModal"}} />
|
||||
@@ -4,17 +4,15 @@ import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
|
||||
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
|
||||
function isGPTBot(user) {
|
||||
return user && [-110, -111, -112, -113].includes(user.id);
|
||||
return user && [-110, -111, -112].includes(user.id);
|
||||
}
|
||||
|
||||
function attachHeaderIcon(api) {
|
||||
const settings = api.container.lookup("service:site-settings");
|
||||
|
||||
const enabledBots = settings.ai_bot_add_to_header
|
||||
const enabledBots = settings.ai_helper_add_ai_pm_to_header
|
||||
? settings.ai_bot_enabled_chat_bots.split("|").filter(Boolean)
|
||||
: [];
|
||||
if (enabledBots.length > 0) {
|
||||
@@ -140,45 +138,25 @@ function initializeAIBotReplies(api) {
|
||||
});
|
||||
}
|
||||
|
||||
function initializePersonaDecorator(api) {
|
||||
let topicController = null;
|
||||
api.decorateWidget(`poster-name:after`, (dec) => {
|
||||
if (!isGPTBot(dec.attrs.user)) {
|
||||
return;
|
||||
}
|
||||
// this is hacky and will need to change
|
||||
// trouble is we need to get the model for the topic
|
||||
// and it is not available in the decorator
|
||||
// long term this will not be a problem once we remove widgets and
|
||||
// have a saner structure for our model
|
||||
topicController =
|
||||
topicController || api.container.lookup("controller:topic");
|
||||
|
||||
return dec.widget.attach("persona-flair", {
|
||||
topicController,
|
||||
});
|
||||
});
|
||||
|
||||
registerWidgetShim(
|
||||
"persona-flair",
|
||||
"span.persona-flair",
|
||||
hbs`{{@data.topicController.model.ai_persona_name}}`
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-ai-bot-replies",
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const user = container.lookup("service:current-user");
|
||||
const aiBotEnaled =
|
||||
settings.discourse_ai_enabled && settings.ai_bot_enabled;
|
||||
|
||||
if (user?.ai_enabled_chat_bots) {
|
||||
if (settings.ai_bot_add_to_header) {
|
||||
withPluginApi("1.6.0", attachHeaderIcon);
|
||||
}
|
||||
const aiBotsAllowedGroups = settings.ai_bot_allowed_groups
|
||||
.split("|")
|
||||
.map((id) => parseInt(id, 10));
|
||||
const canInteractWithAIBots = user?.groups.some((g) =>
|
||||
aiBotsAllowedGroups.includes(g.id)
|
||||
);
|
||||
|
||||
if (aiBotEnaled && canInteractWithAIBots) {
|
||||
withPluginApi("1.6.0", attachHeaderIcon);
|
||||
withPluginApi("1.6.0", initializeAIBotReplies);
|
||||
withPluginApi("1.6.0", initializePersonaDecorator);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
|
||||
function initializeComposerAIHelper(api) {
|
||||
api.modifyClass("component:composer-editor", {
|
||||
pluginId: "discourse-ai",
|
||||
|
||||
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: "discourse-sparkles",
|
||||
className: "composer-ai-helper",
|
||||
sendAction: () => toolbar.context.send("openAIHelper"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-ai-composer-helper",
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
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((id) => parseInt(id, 10));
|
||||
const canUseAssistant = user?.groups.some((g) =>
|
||||
allowedGroups.includes(g.id)
|
||||
);
|
||||
|
||||
if (helperEnabled && canUseAssistant) {
|
||||
withPluginApi("1.6.0", initializeComposerAIHelper);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { translateResults, updateRecentSearches } from "discourse/lib/search";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
const SEMANTIC_SEARCH = "semantic_search";
|
||||
|
||||
function initializeSemanticSearch(api) {
|
||||
api.addFullPageSearchType(
|
||||
"discourse_ai.embeddings.semantic_search",
|
||||
SEMANTIC_SEARCH,
|
||||
(searchController, args) => {
|
||||
if (searchController.currentUser) {
|
||||
updateRecentSearches(searchController.currentUser, args.searchTerm);
|
||||
}
|
||||
|
||||
ajax("/discourse-ai/embeddings/semantic-search", { data: args })
|
||||
.then(async (results) => {
|
||||
const model = (await translateResults(results)) || {};
|
||||
|
||||
if (results.grouped_search_result) {
|
||||
searchController.set("q", results.grouped_search_result.term);
|
||||
}
|
||||
|
||||
if (args.page > 1) {
|
||||
if (model) {
|
||||
searchController.model.posts.pushObjects(model.posts);
|
||||
searchController.model.topics.pushObjects(model.topics);
|
||||
searchController.model.set(
|
||||
"grouped_search_result",
|
||||
results.grouped_search_result
|
||||
);
|
||||
}
|
||||
} else {
|
||||
model.grouped_search_result = results.grouped_search_result;
|
||||
searchController.set("model", model);
|
||||
}
|
||||
searchController.set("error", null);
|
||||
})
|
||||
.catch((e) => {
|
||||
searchController.set("error", e.jqXHR.responseJSON?.message);
|
||||
})
|
||||
.finally(() => {
|
||||
searchController.setProperties({
|
||||
searching: false,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-ai-full-page-semantic-search",
|
||||
|
||||
initialize(container) {
|
||||
const settings = container.lookup("service:site-settings");
|
||||
const semanticSearch = settings.ai_embeddings_semantic_search_enabled;
|
||||
|
||||
if (settings.ai_embeddings_enabled && semanticSearch) {
|
||||
withPluginApi("1.6.0", initializeSemanticSearch);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2,16 +2,9 @@ nav.post-controls .actions button.cancel-streaming {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-bot-chat {
|
||||
#reply-control {
|
||||
.title-and-category,
|
||||
#private-message-users {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.gpt-persona {
|
||||
margin-bottom: 5px;
|
||||
margin-top: -10px;
|
||||
.ai-bot-chat #reply-control {
|
||||
.title-and-category {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +39,3 @@ article.streaming nav.post-controls .actions button.cancel-streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topic-body .persona-flair {
|
||||
order: 2;
|
||||
font-size: var(--font-down-1);
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
.composer-ai-helper-modal {
|
||||
.combobox,
|
||||
.text-preview,
|
||||
.ai-helper-waiting-selection {
|
||||
margin: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
.text-preview,
|
||||
.inline-diff {
|
||||
ins {
|
||||
@@ -25,6 +31,11 @@
|
||||
background-color: var(--success-low);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.selection-hint {
|
||||
font-size: var(--font-down-2);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-above-suggested-outlet.related-topics {
|
||||
@@ -33,68 +44,46 @@
|
||||
|
||||
.ai-helper-context-menu {
|
||||
background: var(--secondary);
|
||||
box-shadow: var(--shadow-card);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
padding: 0.25rem;
|
||||
max-width: 25rem;
|
||||
border: 1px solid var(--primary-low);
|
||||
list-style: none;
|
||||
z-index: 999;
|
||||
|
||||
&.out-of-bounds {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
.btn-flat {
|
||||
justify-content: left;
|
||||
text-align: left;
|
||||
background: none;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding-block: 0.6rem;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
background: var(--d-hover);
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.d-button-label {
|
||||
color: var(--primary-very-high);
|
||||
}
|
||||
ul:not(.ai-helper-context-menu__loading) li {
|
||||
transition: background-color 0.25s ease;
|
||||
&:hover {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
}
|
||||
|
||||
.d-button-label {
|
||||
color: var(--primary-very-high);
|
||||
}
|
||||
|
||||
&__options {
|
||||
padding: 0.25rem;
|
||||
|
||||
li:not(:last-child) {
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
.dot-falling {
|
||||
margin-inline: 1rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__resets {
|
||||
@@ -108,41 +97,6 @@
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
&__custom-prompt {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
padding: 0.5rem;
|
||||
|
||||
&-header {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.btn {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-custom-prompt-input {
|
||||
min-height: 90px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-custom-prompt {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&__input {
|
||||
background: var(--primary-low);
|
||||
border-color: var(--primary-low);
|
||||
margin-bottom: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.d-editor-input.loading {
|
||||
@@ -239,83 +193,3 @@
|
||||
box-shadow: 10014px 15px 0 0 rgba(152, 128, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest Titles Related
|
||||
.suggest-titles-button {
|
||||
display: block;
|
||||
align-self: baseline;
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--primary-medium);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.title-input:has(.suggestion-button) {
|
||||
// border on focus should be on top of suggestion button
|
||||
input:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-button {
|
||||
.d-icon-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.suggest-tags-button,
|
||||
.suggest-category-button {
|
||||
display: block;
|
||||
align-self: baseline;
|
||||
border: 1px solid var(--primary-medium);
|
||||
border-left: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ai-suggestions-menu {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 1.5rem;
|
||||
max-width: 25rem;
|
||||
width: unset;
|
||||
z-index: 999;
|
||||
|
||||
&__errors {
|
||||
background: var(--danger);
|
||||
padding: 0.25rem 1em;
|
||||
color: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.category-input:has(.ai-suggestions-menu) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Prevent suggestion button from wrapping on smaller screens
|
||||
@media screen and (max-width: 768px) {
|
||||
#reply-control {
|
||||
.category-input:has(.suggestion-button) {
|
||||
.category-chooser {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
+ .mini-tag-chooser {
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggest-tags-button + .ai-suggestions-menu {
|
||||
top: 4.25rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
.semantic-search__container {
|
||||
background: var(--primary-very-low);
|
||||
margin: 1rem 0 1rem 0;
|
||||
|
||||
.semantic-search__results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: baseline;
|
||||
|
||||
.semantic-search {
|
||||
&__searching-text {
|
||||
display: inline-block;
|
||||
margin-left: 3px;
|
||||
}
|
||||
&__indicator-wave {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
}
|
||||
&__indicator-dot {
|
||||
display: inline-block;
|
||||
animation: ai-summary__indicator-wave 1.8s linear infinite;
|
||||
&:nth-child(2) {
|
||||
animation-delay: -1.6s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: -1.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.semantic-search__entries {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.semantic-search__searching {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,12 @@ ar:
|
||||
modals:
|
||||
select_option: "حدِّد خيارًا..."
|
||||
related_topics:
|
||||
title: "موضوعات مشابهة"
|
||||
pill: "موضوعات ذات صلة"
|
||||
ai_helper:
|
||||
title: "اقتراح تغييرات باستخدام الذكاء الاصطناعي"
|
||||
description: "اختر أحد الخيارات أدناه، وسيقترح عليك الذكاء الاصطناعي نسخة جديدة من النص."
|
||||
selection_hint: "تلميح: يمكنك أيضًا تحديد جزء من النص قبل فتح أداة المساعدة لإعادة كتابة ذلك الجزء فقط."
|
||||
context_menu:
|
||||
back: "الرجوع"
|
||||
undo: "تراجع"
|
||||
cancel: "إلغاء"
|
||||
regen: "إعادة المحاولة"
|
||||
confirm: "تأكيد"
|
||||
revert: "عودة"
|
||||
reviewables:
|
||||
model_used: "النموذج المستخدم:"
|
||||
accuracy: "الدقة:"
|
||||
|
||||
@@ -5,12 +5,3 @@
|
||||
# https://translate.discourse.org/
|
||||
|
||||
be:
|
||||
js:
|
||||
discourse_ai:
|
||||
ai_helper:
|
||||
context_menu:
|
||||
back: "Назад"
|
||||
undo: "Адмяніць"
|
||||
cancel: "адмяніць"
|
||||
regen: "Паспрабаваць яшчэ раз"
|
||||
revert: "Вярнуць"
|
||||
|
||||
@@ -5,12 +5,3 @@
|
||||
# https://translate.discourse.org/
|
||||
|
||||
bg:
|
||||
js:
|
||||
discourse_ai:
|
||||
ai_helper:
|
||||
context_menu:
|
||||
back: "Назад"
|
||||
undo: "Отмени"
|
||||
cancel: "Прекрати"
|
||||
regen: "Опитайте отново"
|
||||
revert: "Върни"
|
||||
|
||||
@@ -5,12 +5,3 @@
|
||||
# https://translate.discourse.org/
|
||||
|
||||
bs_BA:
|
||||
js:
|
||||
discourse_ai:
|
||||
ai_helper:
|
||||
context_menu:
|
||||
back: "Prethodno"
|
||||
undo: "Vrati nazad"
|
||||
cancel: "Odustani"
|
||||
regen: "Pokušaj ponovo"
|
||||
revert: "Vrati nazad"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user