Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee2081356 | ||
|
|
e6bfec5bdb |
@@ -1,4 +1,3 @@
|
|||||||
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
|
|
||||||
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
|
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
|
||||||
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
|
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
|
||||||
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
||||||
|
|||||||
+1
-3
@@ -31,8 +31,7 @@ GEM
|
|||||||
racc (1.7.3)
|
racc (1.7.3)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
regexp_parser (2.9.0)
|
regexp_parser (2.9.0)
|
||||||
rexml (3.2.8)
|
rexml (3.2.6)
|
||||||
strscan (>= 3.0.9)
|
|
||||||
rubocop (1.61.0)
|
rubocop (1.61.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (>= 3.17.0)
|
language_server-protocol (>= 3.17.0)
|
||||||
@@ -61,7 +60,6 @@ GEM
|
|||||||
rubocop-capybara (~> 2.17)
|
rubocop-capybara (~> 2.17)
|
||||||
rubocop-factory_bot (~> 2.22)
|
rubocop-factory_bot (~> 2.22)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
strscan (3.1.0)
|
|
||||||
syntax_tree (6.2.0)
|
syntax_tree (6.2.0)
|
||||||
prettier_print (>= 1.2.0)
|
prettier_print (>= 1.2.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
|
||||||
|
|
||||||
export default DiscourseRoute.extend({
|
|
||||||
async model() {
|
|
||||||
const record = this.store.createRecord("ai-llm");
|
|
||||||
return record;
|
|
||||||
},
|
|
||||||
|
|
||||||
setupController(controller, model) {
|
|
||||||
this._super(controller, model);
|
|
||||||
controller.set(
|
|
||||||
"allLlms",
|
|
||||||
this.modelFor("adminPlugins.show.discourse-ai-llms")
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
|
||||||
|
|
||||||
export default DiscourseRoute.extend({
|
|
||||||
async model(params) {
|
|
||||||
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
|
|
||||||
const id = parseInt(params.id, 10);
|
|
||||||
return allLlms.findBy("id", id);
|
|
||||||
},
|
|
||||||
|
|
||||||
setupController(controller, model) {
|
|
||||||
this._super(controller, model);
|
|
||||||
controller.set(
|
|
||||||
"allLlms",
|
|
||||||
this.modelFor("adminPlugins.show.discourse-ai-llms")
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
|
||||||
|
|
||||||
export default class DiscourseAiAiLlmsRoute extends DiscourseRoute {
|
|
||||||
model() {
|
|
||||||
return this.store.findAll("ai-llm");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
<AiLlmsListEditor @llms={{this.model}} />
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
<AiLlmsListEditor @llms={{this.allLlms}} @currentLlm={{this.model}} />
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
<AiLlmsListEditor @llms={{this.allLlms}} @currentLlm={{this.model}} />
|
|
||||||
-6
@@ -1,9 +1,3 @@
|
|||||||
<DBreadcrumbsItem as |linkClass|>
|
|
||||||
<LinkTo @route="adminPlugins.show.discourse-ai-personas" class={{linkClass}}>
|
|
||||||
{{@model.plugin.nameTitleized}}
|
|
||||||
</LinkTo>
|
|
||||||
</DBreadcrumbsItem>
|
|
||||||
|
|
||||||
<AiPersonaListEditor
|
<AiPersonaListEditor
|
||||||
@personas={{this.allPersonas}}
|
@personas={{this.allPersonas}}
|
||||||
@currentPersona={{this.model}}
|
@currentPersona={{this.model}}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseAi
|
|
||||||
module Admin
|
|
||||||
class AiLlmsController < ::Admin::AdminController
|
|
||||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
|
||||||
|
|
||||||
def index
|
|
||||||
llms = LlmModel.all
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
ai_llms:
|
|
||||||
ActiveModel::ArraySerializer.new(
|
|
||||||
llms,
|
|
||||||
each_serializer: LlmModelSerializer,
|
|
||||||
root: false,
|
|
||||||
).as_json,
|
|
||||||
meta: {
|
|
||||||
providers: DiscourseAi::Completions::Llm.provider_names,
|
|
||||||
tokenizers:
|
|
||||||
DiscourseAi::Completions::Llm.tokenizer_names.map { |tn|
|
|
||||||
{ id: tn, name: tn.split("::").last }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
llm_model = LlmModel.find(params[:id])
|
|
||||||
render json: LlmModelSerializer.new(llm_model)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
llm_model = LlmModel.new(ai_llm_params)
|
|
||||||
if llm_model.save
|
|
||||||
render json: { ai_persona: llm_model }, status: :created
|
|
||||||
else
|
|
||||||
render_json_error llm_model
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
llm_model = LlmModel.find(params[:id])
|
|
||||||
|
|
||||||
if llm_model.update(ai_llm_params)
|
|
||||||
render json: llm_model
|
|
||||||
else
|
|
||||||
render_json_error llm_model
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test
|
|
||||||
RateLimiter.new(current_user, "llm_test_#{current_user.id}", 3, 1.minute).performed!
|
|
||||||
|
|
||||||
llm_model = LlmModel.new(ai_llm_params)
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.proxy_from_obj(llm_model).generate(
|
|
||||||
"How much is 1 + 1?",
|
|
||||||
user: current_user,
|
|
||||||
feature_name: "llm_validator",
|
|
||||||
)
|
|
||||||
|
|
||||||
render json: { success: true }
|
|
||||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
|
||||||
render json: { success: false, error: e.message }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def ai_llm_params
|
|
||||||
params.require(:ai_llm).permit(
|
|
||||||
:display_name,
|
|
||||||
:name,
|
|
||||||
:provider,
|
|
||||||
:tokenizer,
|
|
||||||
:max_prompt_tokens,
|
|
||||||
:url,
|
|
||||||
:api_key,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -125,7 +125,6 @@ module DiscourseAi
|
|||||||
:rag_chunk_overlap_tokens,
|
:rag_chunk_overlap_tokens,
|
||||||
:rag_conversation_chunks,
|
:rag_conversation_chunks,
|
||||||
:question_consolidator_llm,
|
:question_consolidator_llm,
|
||||||
:allow_chat,
|
|
||||||
allowed_group_ids: [],
|
allowed_group_ids: [],
|
||||||
rag_uploads: [:id],
|
rag_uploads: [:id],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ module DiscourseAi
|
|||||||
if shared_conversation.persisted?
|
if shared_conversation.persisted?
|
||||||
render json: success_json.merge(share_key: shared_conversation.share_key)
|
render json: success_json.merge(share_key: shared_conversation.share_key)
|
||||||
else
|
else
|
||||||
render json:
|
render json: failed_json.merge(error: I18n.t("discourse_ai.share_ai.failed_to_share")),
|
||||||
failed_json.merge(error: I18n.t("discourse_ai.share_ai.errors.failed_to_share")),
|
|
||||||
status: :unprocessable_entity
|
status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -31,9 +30,7 @@ module DiscourseAi
|
|||||||
SharedAiConversation.destroy_conversation(@shared_conversation)
|
SharedAiConversation.destroy_conversation(@shared_conversation)
|
||||||
|
|
||||||
render json:
|
render json:
|
||||||
success_json.merge(
|
success_json.merge(message: I18n.t("discourse_ai.share_ai.conversation_deleted"))
|
||||||
message: I18n.t("discourse_ai.share_ai.errors.conversation_deleted"),
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|||||||
@@ -107,50 +107,20 @@ module DiscourseAi
|
|||||||
status: 502
|
status: 502
|
||||||
end
|
end
|
||||||
|
|
||||||
def random_caption
|
|
||||||
captions = [
|
|
||||||
"A beautiful landscape",
|
|
||||||
"An adorable puppy",
|
|
||||||
"A delicious meal",
|
|
||||||
"A cozy fireplace",
|
|
||||||
"A stunning sunset",
|
|
||||||
"A charming cityscape",
|
|
||||||
"A peaceful garden",
|
|
||||||
"A majestic mountain range",
|
|
||||||
"A captivating work of art",
|
|
||||||
]
|
|
||||||
captions.sample
|
|
||||||
end
|
|
||||||
|
|
||||||
def caption_image
|
def caption_image
|
||||||
image_url = params[:image_url]
|
image_url = params[:image_url]
|
||||||
image_url_type = params[:image_url_type]
|
|
||||||
|
|
||||||
raise Discourse::InvalidParameters.new(:image_url) if !image_url
|
raise Discourse::InvalidParameters.new(:image_url) if !image_url
|
||||||
raise Discourse::InvalidParameters.new(:image_url) if !image_url_type
|
|
||||||
|
|
||||||
if image_url_type == "short_path"
|
|
||||||
image = Upload.find_by(sha1: Upload.sha1_from_short_path(image_url))
|
|
||||||
elsif image_url_type == "short_url"
|
|
||||||
image = Upload.find_by(sha1: Upload.sha1_from_short_url(image_url))
|
|
||||||
else
|
|
||||||
image = upload_from_full_url(image_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
|
image = upload_from_full_url(image_url)
|
||||||
raise Discourse::NotFound if image.blank?
|
raise Discourse::NotFound if image.blank?
|
||||||
final_image_url = get_caption_url(image, image_url)
|
final_image_url = get_caption_url(image, image_url)
|
||||||
|
|
||||||
hijack do
|
hijack do
|
||||||
if Rails.env.development?
|
caption =
|
||||||
sleep 2 # Simulate a delay of 2 seconds
|
DiscourseAi::AiHelper::Assistant.new.generate_image_caption(
|
||||||
caption = random_caption
|
final_image_url,
|
||||||
else
|
current_user,
|
||||||
caption =
|
)
|
||||||
DiscourseAi::AiHelper::Assistant.new.generate_image_caption(
|
|
||||||
final_image_url,
|
|
||||||
current_user,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
render json: {
|
render json: {
|
||||||
caption:
|
caption:
|
||||||
"#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})",
|
"#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ module DiscourseAi
|
|||||||
term: query,
|
term: query,
|
||||||
search_context: guardian,
|
search_context: guardian,
|
||||||
use_pg_headlines_for_excerpt: false,
|
use_pg_headlines_for_excerpt: false,
|
||||||
can_lazy_load_categories: guardian.can_lazy_load_categories?,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
|
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module ::Jobs
|
|
||||||
class CreateAiChatReply < ::Jobs::Base
|
|
||||||
sidekiq_options retry: false
|
|
||||||
|
|
||||||
def execute(args)
|
|
||||||
channel = ::Chat::Channel.find_by(id: args[:channel_id])
|
|
||||||
return if channel.blank?
|
|
||||||
|
|
||||||
message = ::Chat::Message.find_by(id: args[:message_id])
|
|
||||||
return if message.blank?
|
|
||||||
|
|
||||||
personaClass =
|
|
||||||
DiscourseAi::AiBot::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
|
|
||||||
return if personaClass.blank?
|
|
||||||
|
|
||||||
user = User.find_by(id: personaClass.user_id)
|
|
||||||
bot = DiscourseAi::AiBot::Bot.as(user, persona: personaClass.new)
|
|
||||||
|
|
||||||
DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message(
|
|
||||||
message,
|
|
||||||
channel,
|
|
||||||
args[:context_post_ids],
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -11,7 +11,6 @@ class AiApiAuditLog < ActiveRecord::Base
|
|||||||
Gemini = 4
|
Gemini = 4
|
||||||
Vllm = 5
|
Vllm = 5
|
||||||
Cohere = 6
|
Cohere = 6
|
||||||
Ollama = 7
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -22,14 +21,12 @@ end
|
|||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# provider_id :integer not null
|
# provider_id :integer not null
|
||||||
# user_id :integer
|
# user_id :integer
|
||||||
|
# topic_id :integer
|
||||||
|
# post_id :integer
|
||||||
# request_tokens :integer
|
# request_tokens :integer
|
||||||
# response_tokens :integer
|
# response_tokens :integer
|
||||||
# raw_request_payload :string
|
# raw_request_payload :string
|
||||||
# raw_response_payload :string
|
# raw_response_payload :string
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# topic_id :integer
|
|
||||||
# post_id :integer
|
|
||||||
# feature_name :string(255)
|
|
||||||
# language_model :string(255)
|
|
||||||
#
|
#
|
||||||
|
|||||||
+17
-38
@@ -8,7 +8,6 @@ class AiPersona < ActiveRecord::Base
|
|||||||
validates :description, presence: true, length: { maximum: 2000 }
|
validates :description, presence: true, length: { maximum: 2000 }
|
||||||
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
||||||
validate :system_persona_unchangeable, on: :update, if: :system
|
validate :system_persona_unchangeable, on: :update, if: :system
|
||||||
validate :chat_preconditions
|
|
||||||
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
|
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
|
||||||
# leaves some room for growth but sets a maximum to avoid memory issues
|
# leaves some room for growth but sets a maximum to avoid memory issues
|
||||||
# we may want to revisit this in the future
|
# we may want to revisit this in the future
|
||||||
@@ -53,9 +52,9 @@ class AiPersona < ActiveRecord::Base
|
|||||||
.where(enabled: true)
|
.where(enabled: true)
|
||||||
.joins(:user)
|
.joins(:user)
|
||||||
.pluck(
|
.pluck(
|
||||||
"ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm, mentionable, allow_chat",
|
"ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm, mentionable",
|
||||||
)
|
)
|
||||||
.map do |id, user_id, username, allowed_group_ids, default_llm, mentionable, allow_chat|
|
.map do |id, user_id, username, allowed_group_ids, default_llm, mentionable|
|
||||||
{
|
{
|
||||||
id: id,
|
id: id,
|
||||||
user_id: user_id,
|
user_id: user_id,
|
||||||
@@ -63,7 +62,6 @@ class AiPersona < ActiveRecord::Base
|
|||||||
allowed_group_ids: allowed_group_ids,
|
allowed_group_ids: allowed_group_ids,
|
||||||
default_llm: default_llm,
|
default_llm: default_llm,
|
||||||
mentionable: mentionable,
|
mentionable: mentionable,
|
||||||
allow_chat: allow_chat,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -74,20 +72,23 @@ class AiPersona < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.allowed_chat(user: nil)
|
|
||||||
personas = persona_cache[:allowed_chat] ||= persona_users.select { |u| u[:allow_chat] }
|
|
||||||
if user
|
|
||||||
personas.select { |u| user.in_any_groups?(u[:allowed_group_ids]) }
|
|
||||||
else
|
|
||||||
personas
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.mentionables(user: nil)
|
def self.mentionables(user: nil)
|
||||||
all_mentionables =
|
all_mentionables =
|
||||||
persona_cache[:mentionables] ||= persona_users.select do |mentionable|
|
persona_cache[:mentionable_usernames] ||= AiPersona
|
||||||
mentionable[:mentionable]
|
.where(mentionable: true)
|
||||||
end
|
.where(enabled: true)
|
||||||
|
.joins(:user)
|
||||||
|
.pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm")
|
||||||
|
.map do |id, user_id, username, allowed_group_ids, default_llm|
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
user_id: user_id,
|
||||||
|
username: username,
|
||||||
|
allowed_group_ids: allowed_group_ids,
|
||||||
|
default_llm: default_llm,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
if user
|
if user
|
||||||
all_mentionables.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
|
all_mentionables.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
|
||||||
else
|
else
|
||||||
@@ -113,7 +114,6 @@ class AiPersona < ActiveRecord::Base
|
|||||||
vision_max_pixels = self.vision_max_pixels
|
vision_max_pixels = self.vision_max_pixels
|
||||||
rag_conversation_chunks = self.rag_conversation_chunks
|
rag_conversation_chunks = self.rag_conversation_chunks
|
||||||
question_consolidator_llm = self.question_consolidator_llm
|
question_consolidator_llm = self.question_consolidator_llm
|
||||||
allow_chat = self.allow_chat
|
|
||||||
|
|
||||||
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
|
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
|
||||||
if persona_class
|
if persona_class
|
||||||
@@ -133,10 +133,6 @@ class AiPersona < ActiveRecord::Base
|
|||||||
user_id
|
user_id
|
||||||
end
|
end
|
||||||
|
|
||||||
persona_class.define_singleton_method :allow_chat do
|
|
||||||
allow_chat
|
|
||||||
end
|
|
||||||
|
|
||||||
persona_class.define_singleton_method :mentionable do
|
persona_class.define_singleton_method :mentionable do
|
||||||
mentionable
|
mentionable
|
||||||
end
|
end
|
||||||
@@ -256,10 +252,6 @@ class AiPersona < ActiveRecord::Base
|
|||||||
question_consolidator_llm
|
question_consolidator_llm
|
||||||
end
|
end
|
||||||
|
|
||||||
define_singleton_method :allow_chat do
|
|
||||||
allow_chat
|
|
||||||
end
|
|
||||||
|
|
||||||
define_singleton_method :to_s do
|
define_singleton_method :to_s do
|
||||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||||
end
|
end
|
||||||
@@ -350,12 +342,6 @@ class AiPersona < ActiveRecord::Base
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def chat_preconditions
|
|
||||||
if allow_chat && !default_llm
|
|
||||||
errors.add(:default_llm, I18n.t("discourse_ai.ai_bot.personas.default_llm_required"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def system_persona_unchangeable
|
def system_persona_unchangeable
|
||||||
if top_p_changed? || temperature_changed? || system_prompt_changed? || commands_changed? ||
|
if top_p_changed? || temperature_changed? || system_prompt_changed? || commands_changed? ||
|
||||||
name_changed? || description_changed?
|
name_changed? || description_changed?
|
||||||
@@ -400,14 +386,7 @@ end
|
|||||||
# rag_chunk_tokens :integer default(374), not null
|
# rag_chunk_tokens :integer default(374), not null
|
||||||
# rag_chunk_overlap_tokens :integer default(10), not null
|
# rag_chunk_overlap_tokens :integer default(10), not null
|
||||||
# rag_conversation_chunks :integer default(10), not null
|
# rag_conversation_chunks :integer default(10), not null
|
||||||
# role :enum default("bot"), not null
|
|
||||||
# role_category_ids :integer default([]), not null, is an Array
|
|
||||||
# role_tags :string default([]), not null, is an Array
|
|
||||||
# role_group_ids :integer default([]), not null, is an Array
|
|
||||||
# role_whispers :boolean default(FALSE), not null
|
|
||||||
# role_max_responses_per_hour :integer default(50), not null
|
|
||||||
# question_consolidator_llm :text
|
# question_consolidator_llm :text
|
||||||
# allow_chat :boolean default(FALSE), not null
|
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class ChatMessageCustomPrompt < ActiveRecord::Base
|
|
||||||
# belongs_to chat message but going to avoid the cross dependency for now
|
|
||||||
end
|
|
||||||
|
|
||||||
# == Schema Information
|
|
||||||
#
|
|
||||||
# Table name: message_custom_prompts
|
|
||||||
#
|
|
||||||
# id :bigint not null, primary key
|
|
||||||
# message_id :bigint not null
|
|
||||||
# custom_prompt :json not null
|
|
||||||
# created_at :datetime not null
|
|
||||||
# updated_at :datetime not null
|
|
||||||
#
|
|
||||||
# Indexes
|
|
||||||
#
|
|
||||||
# index_message_custom_prompts_on_message_id (message_id) UNIQUE
|
|
||||||
#
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class LlmModel < ActiveRecord::Base
|
|
||||||
def tokenizer_class
|
|
||||||
tokenizer.constantize
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# == Schema Information
|
|
||||||
#
|
|
||||||
# Table name: llm_models
|
|
||||||
#
|
|
||||||
# id :bigint not null, primary key
|
|
||||||
# display_name :string
|
|
||||||
# name :string not null
|
|
||||||
# provider :string not null
|
|
||||||
# tokenizer :string not null
|
|
||||||
# max_prompt_tokens :integer not null
|
|
||||||
# created_at :datetime not null
|
|
||||||
# updated_at :datetime not null
|
|
||||||
# url :string
|
|
||||||
# api_key :string
|
|
||||||
#
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class LlmModelSerializer < ApplicationSerializer
|
|
||||||
root "llm"
|
|
||||||
|
|
||||||
attributes :id, :display_name, :name, :provider, :max_prompt_tokens, :tokenizer, :api_key, :url
|
|
||||||
end
|
|
||||||
@@ -23,8 +23,7 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
|
|||||||
:rag_chunk_tokens,
|
:rag_chunk_tokens,
|
||||||
:rag_chunk_overlap_tokens,
|
:rag_chunk_overlap_tokens,
|
||||||
:rag_conversation_chunks,
|
:rag_conversation_chunks,
|
||||||
:question_consolidator_llm,
|
:question_consolidator_llm
|
||||||
:allow_chat
|
|
||||||
|
|
||||||
has_one :user, serializer: BasicUserSerializer, embed: :object
|
has_one :user, serializer: BasicUserSerializer, embed: :object
|
||||||
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
|
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
|
||||||
|
|||||||
@@ -8,10 +8,5 @@ export default {
|
|||||||
this.route("new");
|
this.route("new");
|
||||||
this.route("show", { path: "/:id" });
|
this.route("show", { path: "/:id" });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.route("discourse-ai-llms", { path: "ai-llms" }, function () {
|
|
||||||
this.route("new");
|
|
||||||
this.route("show", { path: "/:id" });
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import RestAdapter from "discourse/adapters/rest";
|
|
||||||
|
|
||||||
export default class Adapter extends RestAdapter {
|
|
||||||
jsonMode = true;
|
|
||||||
|
|
||||||
basePath() {
|
|
||||||
return "/admin/plugins/discourse-ai/";
|
|
||||||
}
|
|
||||||
|
|
||||||
pathFor(store, type, findArgs) {
|
|
||||||
// removes underscores which are implemented in base
|
|
||||||
let path =
|
|
||||||
this.basePath(store, type, findArgs) +
|
|
||||||
store.pluralize(this.apiNameFor(type));
|
|
||||||
return this.appendQueryParams(path, findArgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
apiNameFor() {
|
|
||||||
return "ai-llm";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ajax } from "discourse/lib/ajax";
|
|
||||||
import RestModel from "discourse/models/rest";
|
|
||||||
|
|
||||||
export default class AiLlm extends RestModel {
|
|
||||||
createProperties() {
|
|
||||||
return this.getProperties(
|
|
||||||
"id",
|
|
||||||
"display_name",
|
|
||||||
"name",
|
|
||||||
"provider",
|
|
||||||
"tokenizer",
|
|
||||||
"max_prompt_tokens",
|
|
||||||
"url",
|
|
||||||
"api_key"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProperties() {
|
|
||||||
const attrs = this.createProperties();
|
|
||||||
attrs.id = this.id;
|
|
||||||
|
|
||||||
return attrs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async testConfig() {
|
|
||||||
return await ajax(`/admin/plugins/discourse-ai/ai-llms/test.json`, {
|
|
||||||
data: { ai_llm: this.createProperties() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ const CREATE_ATTRIBUTES = [
|
|||||||
"rag_chunk_overlap_tokens",
|
"rag_chunk_overlap_tokens",
|
||||||
"rag_conversation_chunks",
|
"rag_conversation_chunks",
|
||||||
"question_consolidator_llm",
|
"question_consolidator_llm",
|
||||||
"allow_chat",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SYSTEM_ATTRIBUTES = [
|
const SYSTEM_ATTRIBUTES = [
|
||||||
@@ -47,7 +46,6 @@ const SYSTEM_ATTRIBUTES = [
|
|||||||
"rag_chunk_overlap_tokens",
|
"rag_chunk_overlap_tokens",
|
||||||
"rag_conversation_chunks",
|
"rag_conversation_chunks",
|
||||||
"question_consolidator_llm",
|
"question_consolidator_llm",
|
||||||
"allow_chat",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
class CommandOption {
|
class CommandOption {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { service } from "@ember/service";
|
|||||||
import { gt } from "truth-helpers";
|
import { gt } from "truth-helpers";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
|
import DMenu from "float-kit/components/d-menu";
|
||||||
import { composeAiBotMessage } from "../lib/ai-bot-helper";
|
import { composeAiBotMessage } from "../lib/ai-bot-helper";
|
||||||
|
import AiBotHeaderPanel from "./ai-bot-header-panel";
|
||||||
|
|
||||||
export default class AiBotHeaderIcon extends Component {
|
export default class AiBotHeaderIcon extends Component {
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
@@ -24,12 +26,24 @@ export default class AiBotHeaderIcon extends Component {
|
|||||||
<template>
|
<template>
|
||||||
{{#if (gt this.bots.length 0)}}
|
{{#if (gt this.bots.length 0)}}
|
||||||
<li>
|
<li>
|
||||||
<DButton
|
{{#if (gt this.bots.length 1)}}
|
||||||
@action={{this.compose}}
|
<DMenu
|
||||||
@icon="robot"
|
@icon="robot"
|
||||||
title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
|
@title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
|
||||||
class="ai-bot-button icon btn-flat"
|
class="ai-bot-button icon btn-flat"
|
||||||
/>
|
>
|
||||||
|
<:content as |args|>
|
||||||
|
<AiBotHeaderPanel @closePanel={{args.close}} />
|
||||||
|
</:content>
|
||||||
|
</DMenu>
|
||||||
|
{{else}}
|
||||||
|
<DButton
|
||||||
|
@icon="robot"
|
||||||
|
@title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
|
||||||
|
class="ai-bot-button icon btn-flat"
|
||||||
|
@action={{this.compose}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<div class="bot-panel ai-bot-available-bot-options">
|
||||||
|
{{#each this.botNames as |bot|}}
|
||||||
|
<DButton
|
||||||
|
@translatedTitle={{bot.humanized}}
|
||||||
|
@translatedLabel={{bot.humanized}}
|
||||||
|
@action={{action "composeMessageWithTargetBot" bot.modelName}}
|
||||||
|
class="btn-flat ai-bot-available-bot-content"
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import I18n from "I18n";
|
||||||
|
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import Component from "@glimmer/component";
|
|
||||||
import { tracked } from "@glimmer/tracking";
|
|
||||||
import { Input } from "@ember/component";
|
|
||||||
import { action } from "@ember/object";
|
|
||||||
import { later } from "@ember/runloop";
|
|
||||||
import { inject as service } from "@ember/service";
|
|
||||||
import BackButton from "discourse/components/back-button";
|
|
||||||
import DButton from "discourse/components/d-button";
|
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
||||||
import icon from "discourse-common/helpers/d-icon";
|
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
|
||||||
import I18n from "discourse-i18n";
|
|
||||||
import ComboBox from "select-kit/components/combo-box";
|
|
||||||
import DTooltip from "float-kit/components/d-tooltip";
|
|
||||||
|
|
||||||
export default class AiLlmEditor extends Component {
|
|
||||||
@service toasts;
|
|
||||||
@service router;
|
|
||||||
|
|
||||||
@tracked isSaving = false;
|
|
||||||
|
|
||||||
@tracked testRunning = false;
|
|
||||||
@tracked testResult = null;
|
|
||||||
@tracked testError = null;
|
|
||||||
|
|
||||||
get selectedProviders() {
|
|
||||||
const t = (provName) => {
|
|
||||||
return I18n.t(`discourse_ai.llms.providers.${provName}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.args.llms.resultSetMeta.providers.map((prov) => {
|
|
||||||
return { id: prov, name: t(prov) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
async save() {
|
|
||||||
this.isSaving = true;
|
|
||||||
const isNew = this.args.model.isNew;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.args.model.save();
|
|
||||||
|
|
||||||
this.args.model.setProperties(result.responseJson.ai_persona);
|
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
this.args.llms.addObject(this.args.model);
|
|
||||||
this.router.transitionTo(
|
|
||||||
"adminPlugins.show.discourse-ai-llms.show",
|
|
||||||
this.args.model
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.toasts.success({
|
|
||||||
data: { message: I18n.t("discourse_ai.llms.saved") },
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
popupAjaxError(e);
|
|
||||||
} finally {
|
|
||||||
later(() => {
|
|
||||||
this.isSaving = false;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
async test() {
|
|
||||||
this.testRunning = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configTestResult = await this.args.model.testConfig();
|
|
||||||
this.testResult = configTestResult.success;
|
|
||||||
|
|
||||||
if (this.testResult) {
|
|
||||||
this.testError = null;
|
|
||||||
} else {
|
|
||||||
this.testError = configTestResult.error;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
popupAjaxError(e);
|
|
||||||
} finally {
|
|
||||||
later(() => {
|
|
||||||
this.testRunning = false;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get testErrorMessage() {
|
|
||||||
return I18n.t("discourse_ai.llms.tests.failure", { error: this.testError });
|
|
||||||
}
|
|
||||||
|
|
||||||
get displayTestResult() {
|
|
||||||
return this.testRunning || this.testResult !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<BackButton
|
|
||||||
@route="adminPlugins.show.discourse-ai-llms"
|
|
||||||
@label="discourse_ai.llms.back"
|
|
||||||
/>
|
|
||||||
<form class="form-horizontal ai-llm-editor">
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{i18n "discourse_ai.llms.display_name"}}</label>
|
|
||||||
<Input
|
|
||||||
class="ai-llm-editor-input ai-llm-editor__display-name"
|
|
||||||
@type="text"
|
|
||||||
@value={{@model.display_name}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{i18n "discourse_ai.llms.name"}}</label>
|
|
||||||
<Input
|
|
||||||
class="ai-llm-editor-input ai-llm-editor__name"
|
|
||||||
@type="text"
|
|
||||||
@value={{@model.name}}
|
|
||||||
/>
|
|
||||||
<DTooltip
|
|
||||||
@icon="question-circle"
|
|
||||||
@content={{I18n.t "discourse_ai.llms.hints.name"}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{I18n.t "discourse_ai.llms.provider"}}</label>
|
|
||||||
<ComboBox
|
|
||||||
@value={{@model.provider}}
|
|
||||||
@content={{this.selectedProviders}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
|
|
||||||
<Input
|
|
||||||
class="ai-llm-editor-input ai-llm-editor__url"
|
|
||||||
@type="text"
|
|
||||||
@value={{@model.url}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{I18n.t "discourse_ai.llms.api_key"}}</label>
|
|
||||||
<Input
|
|
||||||
class="ai-llm-editor-input ai-llm-editor__api-key"
|
|
||||||
@type="text"
|
|
||||||
@value={{@model.api_key}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{I18n.t "discourse_ai.llms.tokenizer"}}</label>
|
|
||||||
<ComboBox
|
|
||||||
@value={{@model.tokenizer}}
|
|
||||||
@content={{@llms.resultSetMeta.tokenizers}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<label>{{i18n "discourse_ai.llms.max_prompt_tokens"}}</label>
|
|
||||||
<Input
|
|
||||||
@type="number"
|
|
||||||
class="ai-llm-editor-input ai-llm-editor__max-prompt-tokens"
|
|
||||||
step="any"
|
|
||||||
min="0"
|
|
||||||
lang="en"
|
|
||||||
@value={{@model.max_prompt_tokens}}
|
|
||||||
/>
|
|
||||||
<DTooltip
|
|
||||||
@icon="question-circle"
|
|
||||||
@content={{I18n.t "discourse_ai.llms.hints.max_prompt_tokens"}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-group ai-llm-editor__action_panel">
|
|
||||||
<DButton
|
|
||||||
class="ai-llm-editor__test"
|
|
||||||
@action={{this.test}}
|
|
||||||
@disabled={{this.testRunning}}
|
|
||||||
>
|
|
||||||
{{I18n.t "discourse_ai.llms.tests.title"}}
|
|
||||||
</DButton>
|
|
||||||
|
|
||||||
<DButton
|
|
||||||
class="btn-primary ai-llm-editor__save"
|
|
||||||
@action={{this.save}}
|
|
||||||
@disabled={{this.isSaving}}
|
|
||||||
>
|
|
||||||
{{I18n.t "discourse_ai.llms.save"}}
|
|
||||||
</DButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-group ai-llm-editor-tests">
|
|
||||||
{{#if this.displayTestResult}}
|
|
||||||
{{#if this.testRunning}}
|
|
||||||
<div class="spinner small"></div>
|
|
||||||
{{I18n.t "discourse_ai.llms.tests.running"}}
|
|
||||||
{{else}}
|
|
||||||
{{#if this.testResult}}
|
|
||||||
<div class="ai-llm-editor-tests__success">
|
|
||||||
{{icon "check"}}
|
|
||||||
{{I18n.t "discourse_ai.llms.tests.success"}}
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="ai-llm-editor-tests__failure">
|
|
||||||
{{icon "times"}}
|
|
||||||
{{this.testErrorMessage}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import Component from "@glimmer/component";
|
|
||||||
import { concat } from "@ember/helper";
|
|
||||||
import { LinkTo } from "@ember/routing";
|
|
||||||
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
|
|
||||||
import icon from "discourse-common/helpers/d-icon";
|
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
|
||||||
import I18n from "discourse-i18n";
|
|
||||||
import AiLlmEditor from "./ai-llm-editor";
|
|
||||||
|
|
||||||
export default class AiLlmsListEditor extends Component {
|
|
||||||
get hasLLMElements() {
|
|
||||||
return this.args.llms.length !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DBreadcrumbsItem as |linkClass|>
|
|
||||||
<LinkTo
|
|
||||||
@route="adminPlugins.show.discourse-ai-personas"
|
|
||||||
class={{linkClass}}
|
|
||||||
>
|
|
||||||
{{i18n "discourse_ai.llms.short_title"}}
|
|
||||||
</LinkTo>
|
|
||||||
</DBreadcrumbsItem>
|
|
||||||
|
|
||||||
<section class="ai-llms-list-editor admin-detail pull-left">
|
|
||||||
{{#if @currentLlm}}
|
|
||||||
<AiLlmEditor @model={{@currentLlm}} @llms={{@llms}} />
|
|
||||||
{{else}}
|
|
||||||
<div class="ai-llms-list-editor__header">
|
|
||||||
<h3>{{i18n "discourse_ai.llms.short_title"}}</h3>
|
|
||||||
{{#unless @currentLlm.isNew}}
|
|
||||||
<LinkTo
|
|
||||||
@route="adminPlugins.show.discourse-ai-llms.new"
|
|
||||||
class="btn btn-small btn-primary"
|
|
||||||
>
|
|
||||||
{{icon "plus"}}
|
|
||||||
<span>{{I18n.t "discourse_ai.llms.new"}}</span>
|
|
||||||
</LinkTo>
|
|
||||||
{{/unless}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{#if this.hasLLMElements}}
|
|
||||||
<table class="content-list ai-persona-list-editor">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{i18n "discourse_ai.llms.display_name"}}</th>
|
|
||||||
<th>{{i18n "discourse_ai.llms.provider"}}</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{#each @llms as |llm|}}
|
|
||||||
<tr data-persona-id={{llm.id}} class="ai-llm-list__row">
|
|
||||||
<td><strong>{{llm.display_name}}</strong></td>
|
|
||||||
<td>{{i18n
|
|
||||||
(concat "discourse_ai.llms.providers." llm.provider)
|
|
||||||
}}</td>
|
|
||||||
<td>
|
|
||||||
<LinkTo
|
|
||||||
@route="adminPlugins.show.discourse-ai-llms.show"
|
|
||||||
current-when="true"
|
|
||||||
class="btn btn-text btn-small"
|
|
||||||
@model={{llm}}
|
|
||||||
>{{i18n "discourse_ai.llms.edit"}}</LinkTo>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user