Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a3a50d86 | ||
|
|
775610b1c2 | ||
|
|
54a8dd9556 | ||
|
|
5a4598a7b4 | ||
|
|
dff9f33a97 | ||
|
|
6282b6d21f | ||
|
|
419c43592a | ||
|
|
df8804afcd | ||
|
|
02efca162e | ||
|
|
8a01751991 | ||
|
|
3064d4c288 | ||
|
|
53b7f031ba | ||
|
|
787cd1bf17 | ||
|
|
432590f8d9 | ||
|
|
c55014839f | ||
|
|
e0691e70e8 | ||
|
|
493b48477a | ||
|
|
98c89953d3 | ||
|
|
5b5edb22c6 | ||
|
|
491111e5c8 | ||
|
|
f7277d244e | ||
|
|
d1f21c78f1 | ||
|
|
e1a7d07a91 |
@@ -1,3 +1,4 @@
|
||||
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
||||
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
|
||||
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
|
||||
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
app/assets/stylesheets/vendor/
|
||||
documentation/
|
||||
package.json
|
||||
config/locales/**/*.yml
|
||||
!config/locales/**/*.en*.yml
|
||||
script/import_scripts/**/*.yml
|
||||
|
||||
plugins/**/lib/javascripts/locale
|
||||
public/
|
||||
!/app/assets/javascripts/discourse/public
|
||||
vendor/
|
||||
app/assets/javascripts/discourse/tests/fixtures
|
||||
spec/
|
||||
node_modules/
|
||||
dist/
|
||||
tmp/
|
||||
|
||||
**/*.rb
|
||||
**/*.html
|
||||
**/*.json
|
||||
**/*.md
|
||||
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
before_action :find_ai_persona, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
AiPersona.ordered.map do |persona|
|
||||
# we use a special serializer here cause names and descriptions are
|
||||
# localized for system personas
|
||||
LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
end
|
||||
commands =
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
|
||||
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
|
||||
end
|
||||
render json: { ai_personas: ai_personas, meta: { commands: commands } }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
|
||||
end
|
||||
|
||||
def create
|
||||
ai_persona = AiPersona.new(ai_persona_params)
|
||||
if ai_persona.save
|
||||
render json: { ai_persona: ai_persona }, status: :created
|
||||
else
|
||||
render_json_error ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @ai_persona.update(ai_persona_params)
|
||||
render json: @ai_persona
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @ai_persona.destroy
|
||||
head :no_content
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_ai_persona
|
||||
@ai_persona = AiPersona.find(params[:id])
|
||||
end
|
||||
|
||||
def ai_persona_params
|
||||
params.require(:ai_persona).permit(
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system_prompt,
|
||||
:enabled,
|
||||
:priority,
|
||||
allowed_group_ids: [],
|
||||
commands: [],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ module DiscourseAi
|
||||
def prompts
|
||||
render json:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts,
|
||||
DiscourseAi::AiHelper::Assistant.new.available_prompts,
|
||||
root: false,
|
||||
),
|
||||
status: 200
|
||||
@@ -29,39 +29,34 @@ module DiscourseAi
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, params),
|
||||
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
||||
prompt,
|
||||
input,
|
||||
current_user,
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::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?
|
||||
prompt = CompletionPrompt.enabled_by_name("generate_titles")
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
|
||||
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
||||
prompt,
|
||||
input_hash,
|
||||
input,
|
||||
current_user,
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
@@ -98,21 +93,18 @@ module DiscourseAi
|
||||
|
||||
def explain
|
||||
post_id = get_post_param!
|
||||
text = get_text_param!
|
||||
post = Post.find_by(id: post_id)
|
||||
term_to_explain = get_text_param!
|
||||
post = Post.includes(:topic).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,
|
||||
DiscourseAi::AiHelper::TopicHelper.new(current_user).explain(
|
||||
term_to_explain,
|
||||
post,
|
||||
),
|
||||
status: 200
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
+109
-51
@@ -4,6 +4,13 @@ class AiPersona < ActiveRecord::Base
|
||||
# places a hard limit, so per site we cache a maximum of 500 classes
|
||||
MAX_PERSONAS_PER_SITE = 500
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
|
||||
validates :description, presence: true, length: { maximum: 2000 }
|
||||
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
||||
validate :system_persona_unchangeable, on: :update, if: :system
|
||||
|
||||
before_destroy :ensure_not_system
|
||||
|
||||
class MultisiteHash
|
||||
def initialize(id)
|
||||
@hash = Hash.new { |h, k| h[k] = {} }
|
||||
@@ -38,61 +45,15 @@ class AiPersona < ActiveRecord::Base
|
||||
@persona_cache ||= MultisiteHash.new("persona_cache")
|
||||
end
|
||||
|
||||
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
|
||||
|
||||
def self.all_personas
|
||||
persona_cache[:value] ||= AiPersona
|
||||
.order(:name)
|
||||
.ordered
|
||||
.where(enabled: true)
|
||||
.all
|
||||
.limit(MAX_PERSONAS_PER_SITE)
|
||||
.map do |ai_persona|
|
||||
name = ai_persona.name
|
||||
description = ai_persona.description
|
||||
ai_persona_id = ai_persona.id
|
||||
allowed_group_ids = ai_persona.allowed_group_ids
|
||||
commands =
|
||||
ai_persona.commands.filter_map do |inner_name|
|
||||
begin
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||
define_singleton_method :name do
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
|
||||
define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_singleton_method :inspect do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_method :initialize do |*args, **kwargs|
|
||||
@ai_persona = AiPersona.find_by(id: ai_persona_id)
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
end
|
||||
end
|
||||
.map(&:class_instance)
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
@@ -100,6 +61,99 @@ class AiPersona < ActiveRecord::Base
|
||||
def bump_cache
|
||||
self.class.persona_cache.flush!
|
||||
end
|
||||
|
||||
def class_instance
|
||||
allowed_group_ids = self.allowed_group_ids
|
||||
id = self.id
|
||||
system = self.system
|
||||
|
||||
persona_class = DiscourseAi::AiBot::Personas.system_personas_by_id[self.id]
|
||||
if persona_class
|
||||
persona_class.define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :id do
|
||||
id
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :system do
|
||||
system
|
||||
end
|
||||
|
||||
return persona_class
|
||||
end
|
||||
|
||||
name = self.name
|
||||
description = self.description
|
||||
ai_persona_id = self.id
|
||||
commands =
|
||||
self.commands.filter_map do |inner_name|
|
||||
begin
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||
define_singleton_method :id do
|
||||
id
|
||||
end
|
||||
|
||||
define_singleton_method :name do
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
|
||||
define_singleton_method :system do
|
||||
system
|
||||
end
|
||||
|
||||
define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_singleton_method :inspect do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_method :initialize do |*args, **kwargs|
|
||||
@ai_persona = AiPersona.find_by(id: ai_persona_id)
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_persona_unchangeable
|
||||
if system_prompt_changed? || commands_changed? || name_changed? || description_changed?
|
||||
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_not_system
|
||||
if system
|
||||
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
@@ -110,10 +164,14 @@ end
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :string default([]), not null, is an Array
|
||||
# system_prompt :string not null
|
||||
# system_prompt :string(10000000) not null
|
||||
# allowed_group_ids :integer default([]), not null, is an Array
|
||||
# created_by_id :integer
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# system :boolean default(FALSE), not null
|
||||
# priority :integer default(0), not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CompletionPrompt < ActiveRecord::Base
|
||||
# TODO(roman): Remove sept 2023.
|
||||
self.ignored_columns = ["value"]
|
||||
# TODO(roman): Remove may 2024.
|
||||
self.ignored_columns = ["provider"]
|
||||
|
||||
TRANSLATE = -301
|
||||
GENERATE_TITLES = -302
|
||||
PROOFREAD = -303
|
||||
MARKDOWN_TABLE = -304
|
||||
CUSTOM_PROMPT = -305
|
||||
EXPLAIN = -306
|
||||
|
||||
enum :prompt_type, { text: 0, list: 1, diff: 2 }
|
||||
|
||||
validates :messages, length: { maximum: 20 }
|
||||
validate :each_message_length
|
||||
|
||||
def messages_with_user_input(user_input)
|
||||
return messages unless user_input.present?
|
||||
def self.enabled_by_name(name)
|
||||
where(enabled: true).find_by(name: name)
|
||||
end
|
||||
|
||||
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
|
||||
def messages_with_input(input)
|
||||
return unless input
|
||||
|
||||
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])
|
||||
end
|
||||
messages_hash.merge(input: "<input>#{input}</input")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messages_hash
|
||||
@messages_hash ||= messages.symbolize_keys!
|
||||
end
|
||||
|
||||
def each_message_length
|
||||
messages.each_with_index do |msg, idx|
|
||||
next if msg["content"].length <= 1000
|
||||
@@ -56,7 +53,6 @@ end
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# messages :jsonb
|
||||
# provider :text
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||
root "ai_persona"
|
||||
|
||||
attributes :id,
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system,
|
||||
:priority,
|
||||
:commands,
|
||||
:system_prompt,
|
||||
:allowed_group_ids
|
||||
|
||||
def name
|
||||
object.class_instance.name
|
||||
end
|
||||
|
||||
def description
|
||||
object.class_instance.description
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
resource: "admin.adminPlugins",
|
||||
|
||||
path: "/plugins",
|
||||
|
||||
map() {
|
||||
this.route("discourse-ai", { path: "discourse-ai" }, function () {
|
||||
this.route("ai-personas", { path: "ai_personas" }, function () {
|
||||
this.route("new", { path: "/new" });
|
||||
this.route("show", { path: "/:id" });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class Adapter extends RestAdapter {
|
||||
jsonMode = true;
|
||||
|
||||
basePath() {
|
||||
return "/admin/plugins/discourse-ai/";
|
||||
}
|
||||
|
||||
pathFor() {
|
||||
return super.pathFor(...arguments) + ".json";
|
||||
}
|
||||
|
||||
apiNameFor() {
|
||||
return "ai-persona";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
const ATTRIBUTES = [
|
||||
"name",
|
||||
"description",
|
||||
"commands",
|
||||
"system_prompt",
|
||||
"allowed_group_ids",
|
||||
"enabled",
|
||||
"system",
|
||||
"priority",
|
||||
];
|
||||
|
||||
export default class AiPersona extends RestModel {
|
||||
updateProperties() {
|
||||
let attrs = this.getProperties(ATTRIBUTES);
|
||||
attrs.id = this.id;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
createProperties() {
|
||||
return this.getProperties(ATTRIBUTES);
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
return AiPersona.create(this.createProperties());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { computed, observer } from "@ember/object";
|
||||
import MultiSelectComponent from "select-kit/components/multi-select";
|
||||
|
||||
export default MultiSelectComponent.extend({
|
||||
_modelDisabledChanged: observer("attrs.disabled", function () {
|
||||
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
|
||||
}),
|
||||
|
||||
content: computed(function () {
|
||||
return this.attrs.commands.value;
|
||||
}),
|
||||
|
||||
value: "",
|
||||
|
||||
selectKitOptions: {
|
||||
filterable: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import { later } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import Textarea from "discourse/components/d-textarea";
|
||||
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Group from "discourse/models/group";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
import GroupChooser from "select-kit/components/group-chooser";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import AiCommandSelector from "./ai-command-selector";
|
||||
|
||||
export default class PersonaEditor extends Component {
|
||||
@service router;
|
||||
@service store;
|
||||
@service dialog;
|
||||
@service toasts;
|
||||
|
||||
@tracked allGroups = [];
|
||||
@tracked isSaving = false;
|
||||
@tracked editingModel = null;
|
||||
@tracked showDelete = false;
|
||||
|
||||
@action
|
||||
updateModel() {
|
||||
this.editingModel = this.args.model.workingCopy();
|
||||
this.showDelete = !this.args.model.isNew && !this.args.model.system;
|
||||
}
|
||||
|
||||
@action
|
||||
async updateAllGroups() {
|
||||
this.allGroups = await Group.findAll();
|
||||
}
|
||||
|
||||
@action
|
||||
async save() {
|
||||
const isNew = this.args.model.isNew;
|
||||
this.isSaving = true;
|
||||
|
||||
const backupModel = this.args.model.workingCopy();
|
||||
|
||||
this.args.model.setProperties(this.editingModel);
|
||||
try {
|
||||
await this.args.model.save();
|
||||
this.#sortPersonas();
|
||||
if (isNew) {
|
||||
this.args.personas.addObject(this.args.model);
|
||||
this.router.transitionTo(
|
||||
"adminPlugins.discourse-ai.ai-personas.show",
|
||||
this.args.model
|
||||
);
|
||||
} else {
|
||||
this.toasts.success({
|
||||
data: { message: I18n.t("discourse_ai.ai_persona.saved") },
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.args.model.setProperties(backupModel);
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
later(() => {
|
||||
this.isSaving = false;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
delete() {
|
||||
return this.dialog.confirm({
|
||||
message: I18n.t("discourse_ai.ai_persona.confirm_delete"),
|
||||
didConfirm: () => {
|
||||
return this.args.model.destroyRecord().then(() => {
|
||||
this.args.personas.removeObject(this.args.model);
|
||||
this.router.transitionTo(
|
||||
"adminPlugins.discourse-ai.ai-personas.index"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
updateAllowedGroups(ids) {
|
||||
this.editingModel.set("allowed_group_ids", ids);
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleEnabled() {
|
||||
this.args.model.set("enabled", !this.args.model.enabled);
|
||||
this.editingModel.set("enabled", this.args.model.enabled);
|
||||
if (!this.args.model.isNew) {
|
||||
try {
|
||||
await this.args.model.update({ enabled: this.args.model.enabled });
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async togglePriority() {
|
||||
this.args.model.set("priority", !this.args.model.priority);
|
||||
this.editingModel.set("priority", this.args.model.priority);
|
||||
if (!this.args.model.isNew) {
|
||||
try {
|
||||
await this.args.model.update({ priority: this.args.model.priority });
|
||||
|
||||
this.#sortPersonas();
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sortPersonas() {
|
||||
const sorted = this.args.personas.toArray().sort((a, b) => {
|
||||
if (a.priority && !b.priority) {
|
||||
return -1;
|
||||
} else if (!a.priority && b.priority) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
this.args.personas.clear();
|
||||
this.args.personas.setObjects(sorted);
|
||||
}
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="form-horizontal ai-persona-editor"
|
||||
{{didUpdate this.updateModel @model.id}}
|
||||
{{didInsert this.updateModel @model.id}}
|
||||
{{didInsert this.updateAllGroups @model.id}}
|
||||
>
|
||||
<div class="control-group">
|
||||
<DToggleSwitch
|
||||
class="ai-persona-editor__enabled"
|
||||
@state={{@model.enabled}}
|
||||
@label="discourse_ai.ai_persona.enabled"
|
||||
{{on "click" this.toggleEnabled}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group ai-persona-editor__priority">
|
||||
<DToggleSwitch
|
||||
class="ai-persona-editor__priority"
|
||||
@state={{@model.priority}}
|
||||
@label="discourse_ai.ai_persona.priority"
|
||||
{{on "click" this.togglePriority}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
||||
<Input
|
||||
class="ai-persona-editor__name"
|
||||
@type="text"
|
||||
@value={{this.editingModel.name}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.description"}}</label>
|
||||
<Textarea
|
||||
class="ai-persona-editor__description"
|
||||
@value={{this.editingModel.description}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
|
||||
<AiCommandSelector
|
||||
class="ai-persona-editor__commands"
|
||||
@value={{this.editingModel.commands}}
|
||||
@disabled={{this.editingModel.system}}
|
||||
@commands={{@personas.resultSetMeta.commands}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
|
||||
<GroupChooser
|
||||
@value={{this.editingModel.allowed_group_ids}}
|
||||
@content={{this.allGroups}}
|
||||
@onChange={{this.updateAllowedGroups}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="ai-persona-editor__system_prompt">{{I18n.t
|
||||
"discourse_ai.ai_persona.system_prompt"
|
||||
}}</label>
|
||||
<Textarea
|
||||
class="ai-persona-editor__system_prompt"
|
||||
@value={{this.editingModel.system_prompt}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group ai-persona-editor__action_panel">
|
||||
<DButton
|
||||
class="btn-primary ai-persona-editor__save"
|
||||
@action={{this.save}}
|
||||
@disabled={{this.isSaving}}
|
||||
>{{I18n.t "discourse_ai.ai_persona.save"}}</DButton>
|
||||
{{#if this.showDelete}}
|
||||
<DButton
|
||||
@action={{this.delete}}
|
||||
class="btn-danger ai-persona-editor__delete"
|
||||
>
|
||||
{{I18n.t "discourse_ai.ai_persona.delete"}}
|
||||
</DButton>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { LinkTo } from "@ember/routing";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { cook } from "discourse/lib/text";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import I18n from "discourse-i18n";
|
||||
import AiPersonaEditor from "./ai-persona-editor";
|
||||
|
||||
export default class AiPersonaListEditor extends Component {
|
||||
@tracked _noPersonaText = null;
|
||||
|
||||
get noPersonaText() {
|
||||
if (this._noPersonaText === null) {
|
||||
const raw = I18n.t("discourse_ai.ai_persona.no_persona_selected");
|
||||
cook(raw).then((result) => {
|
||||
this._noPersonaText = result;
|
||||
});
|
||||
}
|
||||
|
||||
return this._noPersonaText;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-persona-list-editor__header">
|
||||
<h3>{{I18n.t "discourse_ai.ai_persona.title"}}</h3>
|
||||
{{#unless @currentPersona.isNew}}
|
||||
<LinkTo
|
||||
@route="adminPlugins.discourse-ai.ai-personas.new"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{icon "plus"}}
|
||||
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
|
||||
</LinkTo>
|
||||
{{/unless}}
|
||||
</div>
|
||||
<div class="content-list ai-persona-list-editor">
|
||||
<ul>
|
||||
{{#each @personas as |persona|}}
|
||||
<li
|
||||
class={{concatClass
|
||||
(if persona.enabled "" "diabled")
|
||||
(if persona.priority "priority")
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="adminPlugins.discourse-ai.ai-personas.show"
|
||||
current-when="true"
|
||||
@model={{persona}}
|
||||
>{{persona.name}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<section class="ai-persona-list-editor__current content-body">
|
||||
{{#if @currentPersona}}
|
||||
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
|
||||
{{else}}
|
||||
<div class="ai-persona-list-editor__empty">
|
||||
{{this.noPersonaText}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
</template>
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default class AISuggestionDropdown extends Component {
|
||||
const minCharacterCount = 40;
|
||||
const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
|
||||
const composerFields = document.querySelector(".composer-fields");
|
||||
|
||||
|
||||
if (composerFields) {
|
||||
if (isShowAIButton) {
|
||||
composerFields.classList.add("showing-ai-suggestions");
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import Component from '@glimmer/component';
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
|
||||
export default class SearchResultDecoration extends Component {
|
||||
<template>
|
||||
<div class="ai-result__icon">
|
||||
{{icon "discourse-sparkles"}}
|
||||
<span>AI</span>
|
||||
</div>
|
||||
<div class="boosted-result__icon">
|
||||
{{icon "rocket"}}
|
||||
<span>Boosted</span>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { inject as service } from "@ember/service";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
||||
|
||||
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;
|
||||
|
||||
STORE_NAMESPACE = "discourse_ai_persona_selector_";
|
||||
preferredPersonaStore = new KeyValueStore(this.STORE_NAMESPACE);
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (this.botOptions && this.composer) {
|
||||
let personaId = this.preferredPersonaStore.getObject("id");
|
||||
|
||||
this._value = this.botOptions[0].id;
|
||||
if (personaId) {
|
||||
personaId = parseInt(personaId, 10);
|
||||
if (this.botOptions.any((bot) => bot.id === personaId)) {
|
||||
this._value = personaId;
|
||||
}
|
||||
}
|
||||
|
||||
this.composer.metaData = { ai_persona_id: this._value };
|
||||
}
|
||||
}
|
||||
|
||||
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.id,
|
||||
name: persona.name,
|
||||
description: persona.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(newValue) {
|
||||
this._value = newValue;
|
||||
this.preferredPersonaStore.setObject({ key: "id", value: newValue });
|
||||
this.composer.metaData = { ai_persona_id: newValue };
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="gpt-persona">
|
||||
<DropdownSelectBox
|
||||
class="persona-selector__dropdown"
|
||||
@value={{this.value}}
|
||||
@content={{this.botOptions}}
|
||||
@options={{hash icon="robot"}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
||||
import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { isValidSearchTerm, translateResults } from "discourse/lib/search";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class SemanticSearch extends Component {
|
||||
static shouldRender(_args, { siteSettings }) {
|
||||
return siteSettings.ai_embeddings_semantic_search_enabled;
|
||||
}
|
||||
|
||||
@service appEvents;
|
||||
@service siteSettings;
|
||||
@service searchPreferencesManager;
|
||||
|
||||
@tracked searching = false;
|
||||
@tracked AIResults = [];
|
||||
@tracked showingAIResults = false;
|
||||
@tracked preventAISearch = false;
|
||||
initialSearchTerm = this.args.outletArgs.search;
|
||||
|
||||
get disableToggleSwitch() {
|
||||
if (this.searching || this.AIResults.length === 0 || this.preventAISearch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
get searchStateText() {
|
||||
if (this.preventAISearch) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_disabled_sort");
|
||||
}
|
||||
if (this.searching) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_loading");
|
||||
}
|
||||
|
||||
if (this.AIResults.length === 0) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_results.none");
|
||||
}
|
||||
|
||||
if (this.AIResults.length > 0) {
|
||||
if (this.showingAIResults) {
|
||||
return I18n.t(
|
||||
"discourse_ai.embeddings.semantic_search_results.toggle",
|
||||
{
|
||||
count: this.AIResults.length,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return I18n.t(
|
||||
"discourse_ai.embeddings.semantic_search_results.toggle_hidden",
|
||||
{
|
||||
count: this.AIResults.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get searchTerm() {
|
||||
if (this.initialSearchTerm !== this.args.outletArgs.search) {
|
||||
this.initialSearchTerm = undefined;
|
||||
this.resetAIResults();
|
||||
}
|
||||
|
||||
return this.args.outletArgs.search;
|
||||
}
|
||||
|
||||
get searchEnabled() {
|
||||
return (
|
||||
this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
|
||||
isValidSearchTerm(this.searchTerm, this.siteSettings)
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleAIResults() {
|
||||
if (this.showingAIResults) {
|
||||
this.args.outletArgs.addSearchResults([], "topic_id");
|
||||
} else {
|
||||
this.args.outletArgs.addSearchResults(this.AIResults, "topic_id");
|
||||
}
|
||||
this.showingAIResults = !this.showingAIResults;
|
||||
}
|
||||
|
||||
@action
|
||||
resetAIResults() {
|
||||
this.AIResults = [];
|
||||
this.showingAIResults = false;
|
||||
}
|
||||
|
||||
@action
|
||||
handleSearch() {
|
||||
if (!this.searchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.searchPreferencesManager.sortOrder !== 0) {
|
||||
this.preventAISearch = true;
|
||||
return;
|
||||
} else {
|
||||
this.preventAISearch = false;
|
||||
}
|
||||
|
||||
if (this.initialSearchTerm) {
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
|
||||
withPluginApi("1.15.0", (api) => {
|
||||
api.onAppEvent("full-page-search:trigger-search", () => {
|
||||
return this.performHyDESearch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
performHyDESearch() {
|
||||
this.searching = true;
|
||||
this.resetAIResults();
|
||||
|
||||
ajax("/discourse-ai/embeddings/semantic-search", {
|
||||
data: { q: this.searchTerm },
|
||||
})
|
||||
.then(async (results) => {
|
||||
const model = (await translateResults(results)) || {};
|
||||
|
||||
if (model.posts?.length === 0) {
|
||||
this.searching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
model.posts.forEach((post) => {
|
||||
post.generatedByAI = true;
|
||||
});
|
||||
|
||||
this.AIResults = model.posts;
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => (this.searching = false));
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.searchEnabled}}
|
||||
<div class="semantic-search__container search-results" role="region">
|
||||
<div class="semantic-search__results" {{didInsert this.handleSearch}} {{didUpdate this.handleSearch this.searchTerm}}>
|
||||
<div
|
||||
class="semantic-search__searching
|
||||
{{if this.searching 'in-progress'}}"
|
||||
>
|
||||
<DToggleSwitch
|
||||
disabled={{this.disableToggleSwitch}}
|
||||
@state={{this.showingAIResults}}
|
||||
title="AI search results hidden"
|
||||
class="semantic-search__results-toggle"
|
||||
{{on "click" this.toggleAIResults}}
|
||||
/>
|
||||
|
||||
<div class="semantic-search__searching-text">
|
||||
{{icon "discourse-sparkles"}}
|
||||
{{this.searchStateText}}
|
||||
</div>
|
||||
|
||||
{{#if this.searching}}
|
||||
<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>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
-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 { tracked } from "@glimmer/tracking";
|
||||
import { action, computed } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { isValidSearchTerm, translateResults } from "discourse/lib/search";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -7,6 +7,8 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "I18n";
|
||||
import eq from "truth-helpers/helpers/eq";
|
||||
import { showPostAIHelper } from "../../lib/show-ai-helper";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
|
||||
|
||||
const i18n = I18n.t.bind(I18n);
|
||||
|
||||
@@ -20,6 +22,9 @@ export default class AIHelperOptionsMenu extends Component {
|
||||
@tracked suggestion = "";
|
||||
@tracked showMainButtons = true;
|
||||
|
||||
@tracked copyButtonIcon = "copy";
|
||||
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
|
||||
|
||||
MENU_STATES = {
|
||||
triggers: "TRIGGERS",
|
||||
options: "OPTIONS",
|
||||
@@ -90,6 +95,20 @@ export default class AIHelperOptionsMenu extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
copySuggestion() {
|
||||
if (this.suggestion?.length > 0) {
|
||||
navigator.clipboard.writeText(this.suggestion).then(() => {
|
||||
this.copyButtonIcon = "check";
|
||||
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copied"
|
||||
setTimeout(() => {
|
||||
this.copyButtonIcon = "copy";
|
||||
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy"
|
||||
}, 3500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadPrompts() {
|
||||
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||
|
||||
@@ -146,8 +165,19 @@ export default class AIHelperOptionsMenu extends Component {
|
||||
/>
|
||||
</div>
|
||||
{{else if (eq this.menuState this.MENU_STATES.result)}}
|
||||
<div class="ai-post-helper__suggestion">{{this.suggestion}}</div>
|
||||
<div class="ai-post-helper__suggestion">
|
||||
<div class="ai-post-helper__suggestion__text">
|
||||
{{this.suggestion}}
|
||||
</div>
|
||||
<DButton
|
||||
@class="btn-flat ai-post-helper__suggestion__copy"
|
||||
@icon={{this.copyButtonIcon}}
|
||||
@label={{this.copyButtonLabel}}
|
||||
@action={{this.copySuggestion}}
|
||||
@disabled={{not this.suggestion}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
model() {
|
||||
return this.modelFor("adminPlugins.discourse-ai.ai-personas");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model() {
|
||||
const record = this.store.createRecord("ai-persona");
|
||||
// TL0
|
||||
record.set("allowed_group_ids", [10]);
|
||||
return record;
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
this._super(controller, model);
|
||||
controller.set(
|
||||
"allPersonas",
|
||||
this.modelFor("adminPlugins.discourse-ai.ai-personas")
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model(params) {
|
||||
const allPersonas = this.modelFor("adminPlugins.discourse-ai.ai-personas");
|
||||
const id = parseInt(params.id, 10);
|
||||
return allPersonas.findBy("id", id);
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
this._super(controller, model);
|
||||
controller.set(
|
||||
"allPersonas",
|
||||
this.modelFor("adminPlugins.discourse-ai.ai-personas")
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model() {
|
||||
return this.store.findAll("ai-persona");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
beforeModel() {
|
||||
this.transitionTo("adminPlugins.discourse-ai.ai-personas");
|
||||
},
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
<AiPersonaListEditor @personas={{this.model}} />
|
||||
@@ -0,0 +1,4 @@
|
||||
<AiPersonaListEditor
|
||||
@personas={{this.allPersonas}}
|
||||
@currentPersona={{this.model}}
|
||||
/>
|
||||
@@ -0,0 +1,4 @@
|
||||
<AiPersonaListEditor
|
||||
@personas={{this.allPersonas}}
|
||||
@currentPersona={{this.model}}
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user