Compare commits

..
Author SHA1 Message Date
Sam Saffron 6e6ced4554 Work in progress... post embeddings and version
Feel free to continue any work here, will pick it up again next week
2023-05-12 15:28:12 +10:00
303 changed files with 2461 additions and 1108240 deletions
-2
View File
@@ -1,3 +1 @@
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
3.1.0.beta3: 057fbe1ce6
+2 -2
View File
@@ -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**
@@ -14,15 +14,6 @@ module DiscourseAi
render json: {}, status: 200
end
def show_bot_username
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
bot_username_lower = User.find(bot_user_id).username_lower
render json: { bot_username: bot_username_lower }, status: 200
end
end
end
end
@@ -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,17 @@ module DiscourseAi
use_pg_headlines_for_excerpt: false,
)
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
model =
DiscourseAi::Embeddings::Model.instantiate(
SiteSetting.ai_embeddings_semantic_search_model,
)
if !semantic_search.cached_query?(query)
RateLimiter.new(current_user, "semantic-search", 4, 1.minutes).performed!
end
DiscourseAi::Embeddings::SemanticSearch
.new(guardian, model)
.search_for_topics(query, page)
.each { |topic_post| grouped_results.add(topic_post) }
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
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
class SummaryController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
VALID_TARGETS = %w[chat_channel topic]
def show
raise PluginDisabled unless SiteSetting.ai_summarization_enabled
target_type = params[:target_type]
raise Discourse::InvalidParameters.new(:target_type) if !VALID_TARGETS.include?(target_type)
since = nil
if target_type == "chat_channel"
since = params[:since].to_i
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
target = Chat::Channel.find_by(id: params[:target_id])
raise Discourse::NotFound.new(:chat_channel) if !target
raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(target)
else
target = Topic.find_by(id: params[:target_id])
raise Discourse::NotFound.new(:topic) if !target
raise Discourse::InvalidAccess if !guardian.can_see_topic?(target)
end
RateLimiter.new(
current_user,
"ai_summarization",
6,
SiteSetting.ai_summarization_rate_limit_minutes.minutes,
).performed!
hijack do
summary =
DiscourseAi::Summarization::SummaryGenerator.new(target, current_user).summarize!(since)
render json: { summary: summary }, status: 200
end
end
end
end
end
-1
View File
@@ -4,7 +4,6 @@ class AiApiAuditLog < ActiveRecord::Base
module Provider
OpenAI = 1
Anthropic = 2
HuggingFaceTextGeneration = 3
end
end
+4 -18
View File
@@ -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
-9
View File
@@ -1,9 +0,0 @@
# frozen_string_literal: true
class PostCustomPrompt < ActiveRecord::Base
belongs_to :post
end
class ::Post
has_one :post_custom_prompt, dependent: :destroy
end
@@ -1,11 +1,5 @@
# frozen_string_literal: true
class AiChatChannelSerializer < ApplicationSerializer
attributes :id, :chatable, :chatable_type, :chatable_url, :slug
def title
# Display all participants for a DM.
# For category channels, the argument is ignored.
object.title(nil)
end
attributes :id, :chatable, :chatable_type, :chatable_url, :title, :slug
end
@@ -1,18 +0,0 @@
<div class="bot-panel ai-bot-available-bot-options">
<div class="menu-panel drop-down">
<div class="panel-body">
<div class="panel-body-contents">
<div class="sidebar-hamburger-dropdown">
{{#each this.botNames as |bot|}}
<DButton
@class="btn-flat ai-bot-available-bot-content"
@translatedTitle={{bot.humanized}}
@translatedLabel={{bot.humanized}}
@action={{action "composeMessageWithTargetBot" bot.modelName}}
/>
{{/each}}
</div>
</div>
</div>
</div>
</div>
@@ -1,34 +0,0 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
import I18n from "I18n";
export default class AiBotHeaderPanel extends Component {
@service siteSettings;
@service composer;
@action
composeMessageWithTargetBot(target) {
this.#composeAiBotMessage(target);
}
get botNames() {
return this.enabledBotOptions.map((bot) => {
return {
humanized: I18n.t(`discourse_ai.ai_bot.bot_names.${bot}`),
modelName: bot,
};
});
}
get enabledBotOptions() {
return this.siteSettings.ai_bot_enabled_chat_bots.split("|");
}
#composeAiBotMessage(targetBot) {
this.args.closePanel();
composeAiBotMessage(targetBot, this.composer);
}
}
@@ -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;
}
}
@@ -0,0 +1,32 @@
<DModalBody @title="discourse_ai.summarization.title">
{{#if @allowTimeframe}}
<span>{{i18n "discourse_ai.summarization.description"}}</span>
<ComboBox
@value={{this.sinceHours}}
@content={{this.sinceOptions}}
@onChange={{action this.summarize}}
@valueProperty="value"
@class="summarization-since"
/>
{{/if}}
<div class="channel-summary">
<ConditionalLoadingSpinner @condition={{this.loading}} />
{{#unless this.loading}}
<Textarea @value={{this.summary}} disabled="true" class="summary-area" />
{{/unless}}
</div>
</DModalBody>
<div class="modal-footer">
{{#unless @allowTimeframe}}
<DButton
@class="btn-primary create"
@action={{this.summarize}}
@label="discourse_ai.summarization.summarize"
/>
{{/unless}}
<DModalCancel @close={{route-action "closeModal"}} />
</div>
@@ -0,0 +1,83 @@
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 I18n from "I18n";
export default class AiSummary extends Component {
@tracked sinceHours = null;
@tracked loading = false;
@tracked availableSummaries = {};
@tracked summary = null;
sinceOptions = [
{
name: I18n.t("discourse_ai.summarization.since", { count: 1 }),
value: 1,
},
{
name: I18n.t("discourse_ai.summarization.since", { count: 3 }),
value: 3,
},
{
name: I18n.t("discourse_ai.summarization.since", { count: 6 }),
value: 6,
},
{
name: I18n.t("discourse_ai.summarization.since", { count: 12 }),
value: 12,
},
{
name: I18n.t("discourse_ai.summarization.since", { count: 24 }),
value: 24,
},
{
name: I18n.t("discourse_ai.summarization.since", { count: 72 }),
value: 72,
},
{
name: I18n.t("discourse_ai.summarization.since", { count: 168 }),
value: 168,
},
];
get canSummarize() {
return (!this.args.allowTimeframe || this.sinceHours) && !this.loading;
}
@action
summarize(value) {
this.loading = true;
const attrs = {
target_id: this.args.targetId,
target_type: this.args.targetType,
};
if (this.args.allowTimeframe) {
this.sinceHours = value;
if (this.availableSummaries[this.sinceHours]) {
this.summary = this.availableSummaries[this.sinceHours];
this.loading = false;
return;
} else {
attrs.since = this.sinceHours;
}
}
ajax("/discourse-ai/summarization/summary", {
method: "POST",
data: attrs,
})
.then((data) => {
if (this.args.allowTimeframe) {
this.availableSummaries[this.sinceHours] = data.summary;
this.summary = this.availableSummaries[this.sinceHours];
} else {
this.summary = data.summary;
}
})
.catch(popupAjaxError)
.finally(() => (this.loading = false));
}
}
@@ -1,48 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import DModalCancel from "discourse/components/d-modal-cancel";
import I18n from "I18n";
import { htmlSafe } from "@ember/template";
const t = I18n.t.bind(I18n);
export default class ModalDiffModal extends Component {
<template>
<DModal
class="composer-ai-helper-modal"
@title={{t "discourse_ai.ai_helper.context_menu.changes"}}
@closeModal={{@closeModal}}
>
<:body>
{{#if @diff}}
{{htmlSafe @diff}}
{{else}}
<div class="composer-ai-helper-modal__old-value">
{{@oldValue}}
</div>
<div class="composer-ai-helper-modal__new-value">
{{@newValue}}
</div>
{{/if}}
</:body>
<:footer>
<DButton
class="btn-primary confirm"
@action={{this.triggerConfirmChanges}}
@label="discourse_ai.ai_helper.context_menu.confirm"
/>
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>
</template>
@action
triggerConfirmChanges() {
this.args.closeModal();
this.args.confirm();
}
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -1,128 +0,0 @@
<div {{did-insert this.setupContextMenu}}>
{{#if this.showContextMenu}}
<div class="ai-helper-context-menu">
{{#if (eq this.menuState this.CONTEXT_MENU_STATES.triggers)}}
<ul class="ai-helper-context-menu__trigger">
<li>
<DButton
@icon="discourse-sparkles"
@action={{this.toggleAiHelperOptions}}
@label="discourse_ai.ai_helper.context_menu.trigger"
class="btn-flat"
/>
</li>
</ul>
{{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)}}
/>
<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}}
{{/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>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.review)}}
<ul class="ai-helper-context-menu__review">
<li>
<DButton
@icon="exchange-alt"
@label="discourse_ai.ai_helper.context_menu.view_changes"
@action={{this.viewChanges}}
class="btn-flat view-changes"
/>
</li>
<li>
<DButton
@icon="undo"
@label="discourse_ai.ai_helper.context_menu.revert"
@action={{this.undoAIAction}}
class="btn-flat revert"
/>
</li>
<li>
<DButton
@icon="check"
@label="discourse_ai.ai_helper.context_menu.confirm"
@action={{this.confirmChanges}}
class="btn-flat confirm"
/>
</li>
</ul>
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.resets)}}
<ul class="ai-helper-context-menu__resets">
<li>
<DButton
@icon="undo"
@label="discourse_ai.ai_helper.context_menu.undo"
@action={{this.undoAIAction}}
class="btn-flat undo"
/>
</li>
<li>
<DButton
@icon="discourse-sparkles"
@label="discourse_ai.ai_helper.context_menu.regen"
@action={{this.updateSelected}}
@actionParam={{this.lastUsedOption}}
class="btn-flat"
/>
</li>
</ul>
{{/if}}
</div>
{{/if}}
</div>
{{#if this.showDiffModal}}
<Modal::DiffModal
@confirm={{this.confirmChanges}}
@diff={{this.diff}}
@oldValue={{this.selectedText}}
@newValue={{this.newSelectedText}}
@closeModal={{fn (mut this.showDiffModal) false}}
/>
{{/if}}
@@ -1,8 +0,0 @@
{{#if this.isAiBotChat}}
<DSection @bodyClass="ai-bot-chat" />
{{#if this.renderChatWarning}}
<div class="ai-bot-chat-warning">{{i18n
"discourse_ai.ai_bot.pm_warning"
}}</div>
{{/if}}
{{/if}}
@@ -1,32 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { computed } from "@ember/object";
export default class extends Component {
@service currentUser;
@service siteSettings;
get composerModel() {
return this.args?.outletArgs?.composer;
}
get renderChatWarning() {
return this.siteSettings.ai_bot_enable_chat_warning;
}
@computed("composerModel.targetRecipients")
get isAiBotChat() {
if (
this.composerModel &&
this.composerModel.targetRecipients &&
this.currentUser.ai_enabled_chat_bots
) {
let reciepients = this.composerModel.targetRecipients.split(",");
return this.currentUser.ai_enabled_chat_bots.any((bot) =>
reciepients.any((username) => username === bot.username)
);
}
return false;
}
}
@@ -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 };
}
}
@@ -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}}
@@ -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);
}
}
@@ -0,0 +1,15 @@
export default {
shouldRender(args) {
return (args.model.related_topics?.length || 0) > 0;
},
setupComponent(args, component) {
if (component.model.related_topics) {
component.set(
"relatedTopics",
component.model.related_topics.map((topic) =>
this.store.createRecord("topic", topic)
)
);
}
},
};

Some files were not shown because too many files have changed in this diff Show More