Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a3a50d86 |
@@ -1,4 +1,3 @@
|
||||
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
|
||||
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
||||
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
|
||||
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
|
||||
|
||||
+20
-15
@@ -2,18 +2,18 @@ GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
ast (2.4.2)
|
||||
json (2.7.1)
|
||||
json (2.6.3)
|
||||
language_server-protocol (3.17.0.3)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.0.3)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.4)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
prettier_print (1.2.1)
|
||||
racc (1.7.3)
|
||||
racc (1.7.2)
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.9.0)
|
||||
regexp_parser (2.8.2)
|
||||
rexml (3.2.6)
|
||||
rubocop (1.59.0)
|
||||
rubocop (1.57.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
@@ -21,19 +21,19 @@ GEM
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-ast (>= 1.28.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.30.0)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-capybara (2.20.0)
|
||||
rubocop-capybara (2.19.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-discourse (3.6.0)
|
||||
rubocop (>= 1.59.0)
|
||||
rubocop-rspec (>= 2.25.0)
|
||||
rubocop-factory_bot (2.25.1)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-rspec (2.26.1)
|
||||
rubocop-discourse (3.4.0)
|
||||
rubocop (>= 1.1.0)
|
||||
rubocop-rspec (>= 2.0.0)
|
||||
rubocop-factory_bot (2.24.0)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-rspec (2.25.0)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
@@ -43,11 +43,16 @@ GEM
|
||||
unicode-display_width (2.5.0)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
ruby
|
||||
x86_64-darwin-18
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
rubocop-discourse
|
||||
syntax_tree
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.4
|
||||
2.4.21
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
before_action :find_ai_persona, only: %i[show update destroy create_user]
|
||||
before_action :find_ai_persona, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
@@ -12,15 +12,11 @@ module DiscourseAi
|
||||
# localized for system personas
|
||||
LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
end
|
||||
tools =
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
|
||||
AiToolSerializer.new(tool, root: false)
|
||||
commands =
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
|
||||
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
|
||||
end
|
||||
llms =
|
||||
DiscourseAi::Configuration::LlmEnumerator.values.map do |hash|
|
||||
{ id: hash[:value], name: hash[:name] }
|
||||
end
|
||||
render json: { ai_personas: ai_personas, meta: { commands: tools, llms: llms } }
|
||||
render json: { ai_personas: ai_personas, meta: { commands: commands } }
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -36,11 +32,6 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
|
||||
def create_user
|
||||
user = @ai_persona.create_user!
|
||||
render json: BasicUserSerializer.new(user, root: "user")
|
||||
end
|
||||
|
||||
def update
|
||||
if @ai_persona.update(ai_persona_params)
|
||||
render json: @ai_persona
|
||||
@@ -64,42 +55,16 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def ai_persona_params
|
||||
permitted =
|
||||
params.require(:ai_persona).permit(
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system_prompt,
|
||||
:priority,
|
||||
:top_p,
|
||||
:temperature,
|
||||
:default_llm,
|
||||
:user_id,
|
||||
:mentionable,
|
||||
:max_context_posts,
|
||||
allowed_group_ids: [],
|
||||
)
|
||||
|
||||
if commands = params.dig(:ai_persona, :commands)
|
||||
permitted[:commands] = permit_commands(commands)
|
||||
end
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
def permit_commands(commands)
|
||||
return [] if !commands.is_a?(Array)
|
||||
|
||||
commands.filter_map do |command, options|
|
||||
break nil if !command.is_a?(String)
|
||||
options&.permit! if options && options.is_a?(ActionController::Parameters)
|
||||
|
||||
if options
|
||||
[command, options]
|
||||
else
|
||||
command
|
||||
end
|
||||
end
|
||||
params.require(:ai_persona).permit(
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system_prompt,
|
||||
:enabled,
|
||||
:priority,
|
||||
allowed_group_ids: [],
|
||||
commands: [],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,21 +8,25 @@ module DiscourseAi
|
||||
before_action :ensure_can_request_suggestions
|
||||
before_action :rate_limiter_performed!, except: %i[prompts]
|
||||
|
||||
def prompts
|
||||
render json:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
DiscourseAi::AiHelper::Assistant.new.available_prompts,
|
||||
root: false,
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest
|
||||
input = get_text_param!
|
||||
|
||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
||||
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
|
||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
||||
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
||||
|
||||
prompt.custom_instruction = params[:custom_prompt]
|
||||
if prompt.prompt_type == "custom_prompt" && params[:custom_prompt].blank?
|
||||
raise Discourse::InvalidParameters.new(:custom_prompt)
|
||||
end
|
||||
|
||||
suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
||||
@@ -32,7 +36,7 @@ module DiscourseAi
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
@@ -52,7 +56,7 @@ module DiscourseAi
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
@@ -77,7 +81,9 @@ module DiscourseAi
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest_thumbnails(input)
|
||||
def suggest_thumbnails
|
||||
input = get_text_param!
|
||||
|
||||
hijack do
|
||||
thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user)
|
||||
|
||||
@@ -92,15 +98,13 @@ module DiscourseAi
|
||||
|
||||
raise Discourse::InvalidParameters.new(:post_id) unless post
|
||||
|
||||
Jobs.enqueue(
|
||||
:stream_post_helper,
|
||||
post_id: post.id,
|
||||
user_id: current_user.id,
|
||||
term_to_explain: term_to_explain,
|
||||
)
|
||||
|
||||
render json: { success: true }, status: 200
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
|
||||
render json:
|
||||
DiscourseAi::AiHelper::TopicHelper.new(current_user).explain(
|
||||
term_to_explain,
|
||||
post,
|
||||
),
|
||||
status: 200
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class GenerateEmbeddings < ::Jobs::Base
|
||||
sidekiq_options queue: "low"
|
||||
|
||||
def execute(args)
|
||||
return unless SiteSetting.ai_embeddings_enabled
|
||||
return if args[:target_type].blank? || args[:target_id].blank?
|
||||
target = args[:target_type].constantize.find_by_id(args[:target_id])
|
||||
return if target.nil? || target.deleted_at.present?
|
||||
|
||||
topic = target.is_a?(Topic) ? target : target.topic
|
||||
post = target.is_a?(Post) ? target : target.first_post
|
||||
return unless topic.present? && post.present?
|
||||
return if topic.private_message? && !SiteSetting.ai_embeddings_generate_for_pms
|
||||
return if post.raw.blank?
|
||||
|
||||
strategy = DiscourseAi::Embeddings::Strategies::Truncation.new
|
||||
vector_rep =
|
||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(strategy)
|
||||
|
||||
vector_rep.generate_representation_from(target)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,35 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class StreamPostHelper < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args)
|
||||
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
return unless user = User.find_by(id: args[:user_id])
|
||||
return unless args[:term_to_explain]
|
||||
|
||||
topic = post.topic
|
||||
reply_to = post.reply_to_post
|
||||
|
||||
guardian = Guardian.new(user)
|
||||
return unless guardian.can_see?(post)
|
||||
|
||||
prompt = CompletionPrompt.enabled_by_name("explain")
|
||||
|
||||
input = <<~TEXT
|
||||
<term>#{args[:term_to_explain]}</term>
|
||||
<context>#{post.raw}</context>
|
||||
<topic>#{topic.title}</topic>
|
||||
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
|
||||
TEXT
|
||||
|
||||
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
|
||||
prompt,
|
||||
input,
|
||||
user,
|
||||
"/discourse-ai/ai-helper/explain/#{post.id}",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,132 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class EmbeddingsBackfill < ::Jobs::Scheduled
|
||||
every 5.minutes
|
||||
sidekiq_options queue: "low"
|
||||
cluster_concurrency 1
|
||||
|
||||
def execute(args)
|
||||
return unless SiteSetting.ai_embeddings_enabled
|
||||
|
||||
limit = SiteSetting.ai_embeddings_backfill_batch_size
|
||||
|
||||
if limit > 50_000
|
||||
limit = 50_000
|
||||
Rails.logger.warn(
|
||||
"Limiting backfill batch size to 50,000 to avoid OOM errors, reduce ai_embeddings_backfill_batch_size to avoid this warning",
|
||||
)
|
||||
end
|
||||
|
||||
rebaked = 0
|
||||
|
||||
strategy = DiscourseAi::Embeddings::Strategies::Truncation.new
|
||||
vector_rep =
|
||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(strategy)
|
||||
table_name = vector_rep.topic_table_name
|
||||
|
||||
topics =
|
||||
Topic
|
||||
.joins("LEFT JOIN #{table_name} ON #{table_name}.topic_id = topics.id")
|
||||
.where(archetype: Archetype.default)
|
||||
.where(deleted_at: nil)
|
||||
.order("topics.bumped_at DESC")
|
||||
.limit(limit - rebaked)
|
||||
|
||||
rebaked += populate_topic_embeddings(vector_rep, topics)
|
||||
|
||||
vector_rep.consider_indexing
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
# Then, we'll try to backfill embeddings for topics that have outdated
|
||||
# embeddings, be it model or strategy version
|
||||
relation = topics.where(<<~SQL)
|
||||
#{table_name}.model_version < #{vector_rep.version}
|
||||
OR
|
||||
#{table_name}.strategy_version < #{strategy.version}
|
||||
SQL
|
||||
|
||||
rebaked += populate_topic_embeddings(vector_rep, relation)
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
# Finally, we'll try to backfill embeddings for topics that have outdated
|
||||
# embeddings due to edits or new replies. Here we only do 10% of the limit
|
||||
relation =
|
||||
topics.where("#{table_name}.updated_at < ?", 7.days.ago).limit((limit - rebaked) / 10)
|
||||
|
||||
populate_topic_embeddings(vector_rep, relation)
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
return unless SiteSetting.ai_embeddings_per_post_enabled
|
||||
|
||||
# Now for posts
|
||||
table_name = vector_rep.post_table_name
|
||||
|
||||
posts =
|
||||
Post
|
||||
.joins("LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id")
|
||||
.where(deleted_at: nil)
|
||||
.limit(limit - rebaked)
|
||||
|
||||
# First, we'll try to backfill embeddings for posts that have none
|
||||
posts
|
||||
.where("#{table_name}.post_id IS NULL")
|
||||
.find_each do |t|
|
||||
vector_rep.generate_representation_from(t)
|
||||
rebaked += 1
|
||||
end
|
||||
|
||||
vector_rep.consider_indexing
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
# Then, we'll try to backfill embeddings for posts that have outdated
|
||||
# embeddings, be it model or strategy version
|
||||
posts
|
||||
.where(<<~SQL)
|
||||
#{table_name}.model_version < #{vector_rep.version}
|
||||
OR
|
||||
#{table_name}.strategy_version < #{strategy.version}
|
||||
SQL
|
||||
.find_each do |t|
|
||||
vector_rep.generate_representation_from(t)
|
||||
rebaked += 1
|
||||
end
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
# Finally, we'll try to backfill embeddings for posts that have outdated
|
||||
# embeddings due to edits. Here we only do 10% of the limit
|
||||
posts
|
||||
.where("#{table_name}.updated_at < ?", 7.days.ago)
|
||||
.order("random()")
|
||||
.limit((limit - rebaked) / 10)
|
||||
.pluck(:id)
|
||||
.each do |id|
|
||||
vector_rep.generate_representation_from(Post.find_by(id: id))
|
||||
rebaked += 1
|
||||
end
|
||||
|
||||
rebaked
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def populate_topic_embeddings(vector_rep, topics)
|
||||
done = 0
|
||||
ids = topics.where("#{vector_rep.topic_table_name}.topic_id IS NULL").pluck("topics.id")
|
||||
|
||||
ids.each do |id|
|
||||
topic = Topic.find_by(id: id)
|
||||
if topic
|
||||
vector_rep.generate_representation_from(topic)
|
||||
done += 1
|
||||
end
|
||||
end
|
||||
done
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiReportMailer < ActionMailer::Base
|
||||
include Email::BuildEmailHelper
|
||||
|
||||
def send_report(to_address, opts = {})
|
||||
build_email(to_address, **opts)
|
||||
end
|
||||
end
|
||||
@@ -5,8 +5,6 @@ class AiApiAuditLog < ActiveRecord::Base
|
||||
OpenAI = 1
|
||||
Anthropic = 2
|
||||
HuggingFaceTextGeneration = 3
|
||||
Gemini = 4
|
||||
Vllm = 5
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+19
-159
@@ -8,10 +8,6 @@ class AiPersona < ActiveRecord::Base
|
||||
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
|
||||
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
|
||||
|
||||
belongs_to :created_by, class_name: "User"
|
||||
belongs_to :user
|
||||
|
||||
before_destroy :ensure_not_system
|
||||
|
||||
@@ -60,23 +56,6 @@ class AiPersona < ActiveRecord::Base
|
||||
.map(&:class_instance)
|
||||
end
|
||||
|
||||
def self.mentionables
|
||||
persona_cache[:mentionable_usernames] ||= AiPersona
|
||||
.where(mentionable: true)
|
||||
.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
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
|
||||
def bump_cache
|
||||
@@ -87,12 +66,8 @@ class AiPersona < ActiveRecord::Base
|
||||
allowed_group_ids = self.allowed_group_ids
|
||||
id = self.id
|
||||
system = self.system
|
||||
user_id = self.user_id
|
||||
mentionable = self.mentionable
|
||||
default_llm = self.default_llm
|
||||
max_context_posts = self.max_context_posts
|
||||
|
||||
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
|
||||
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
|
||||
@@ -106,51 +81,16 @@ class AiPersona < ActiveRecord::Base
|
||||
system
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :user_id do
|
||||
user_id
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :mentionable do
|
||||
mentionable
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :default_llm do
|
||||
default_llm
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :max_context_posts do
|
||||
max_context_posts
|
||||
end
|
||||
|
||||
return persona_class
|
||||
end
|
||||
|
||||
name = self.name
|
||||
description = self.description
|
||||
ai_persona_id = self.id
|
||||
|
||||
options = {}
|
||||
|
||||
tools = self.respond_to?(:commands) ? self.commands : self.tools
|
||||
|
||||
tools =
|
||||
tools.filter_map do |element|
|
||||
inner_name = element
|
||||
current_options = nil
|
||||
|
||||
if element.is_a?(Array)
|
||||
inner_name = element[0]
|
||||
current_options = element[1]
|
||||
end
|
||||
|
||||
# Won't migrate data yet. Let's rewrite to the tool name.
|
||||
inner_name = inner_name.gsub("Command", "")
|
||||
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
|
||||
|
||||
commands =
|
||||
self.commands.filter_map do |inner_name|
|
||||
begin
|
||||
klass = ("DiscourseAi::AiBot::Tools::#{inner_name}").constantize
|
||||
options[klass] = current_options if current_options
|
||||
klass
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
@@ -165,10 +105,6 @@ class AiPersona < ActiveRecord::Base
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :user_id do
|
||||
user_id
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
@@ -181,22 +117,6 @@ class AiPersona < ActiveRecord::Base
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :user_id do
|
||||
user_id
|
||||
end
|
||||
|
||||
define_singleton_method :mentionable do
|
||||
mentionable
|
||||
end
|
||||
|
||||
define_singleton_method :default_llm do
|
||||
default_llm
|
||||
end
|
||||
|
||||
define_singleton_method :max_context_posts do
|
||||
max_context_posts
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
@@ -210,20 +130,8 @@ class AiPersona < ActiveRecord::Base
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :tools do
|
||||
tools
|
||||
end
|
||||
|
||||
define_method :options do
|
||||
options
|
||||
end
|
||||
|
||||
define_method :temperature do
|
||||
@ai_persona&.temperature
|
||||
end
|
||||
|
||||
define_method :top_p do
|
||||
@ai_persona&.top_p
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@@ -232,50 +140,10 @@ class AiPersona < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
FIRST_PERSONA_USER_ID = -1200
|
||||
|
||||
def create_user!
|
||||
raise "User already exists" if user_id && User.exists?(user_id)
|
||||
|
||||
# find the first id smaller than FIRST_USER_ID that is not taken
|
||||
id = nil
|
||||
|
||||
id = DB.query_single(<<~SQL, FIRST_PERSONA_USER_ID, FIRST_PERSONA_USER_ID - 200).first
|
||||
WITH seq AS (
|
||||
SELECT generate_series(?, ?, -1) AS id
|
||||
)
|
||||
SELECT seq.id FROM seq
|
||||
LEFT JOIN users ON users.id = seq.id
|
||||
WHERE users.id IS NULL
|
||||
ORDER BY seq.id DESC
|
||||
SQL
|
||||
|
||||
id = DB.query_single(<<~SQL).first if id.nil?
|
||||
SELECT min(id) - 1 FROM users
|
||||
SQL
|
||||
|
||||
# note .invalid is a reserved TLD which will route nowhere
|
||||
user =
|
||||
User.new(
|
||||
email: "#{SecureRandom.hex}@does-not-exist.invalid",
|
||||
name: name.titleize,
|
||||
username: UserNameSuggester.suggest(name + "_bot"),
|
||||
active: true,
|
||||
approved: true,
|
||||
trust_level: TrustLevel[4],
|
||||
id: id,
|
||||
)
|
||||
user.save!(validate: false)
|
||||
|
||||
update!(user_id: user.id)
|
||||
user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_persona_unchangeable
|
||||
if top_p_changed? || temperature_changed? || system_prompt_changed? || commands_changed? ||
|
||||
name_changed? || description_changed?
|
||||
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
|
||||
@@ -292,26 +160,18 @@ end
|
||||
#
|
||||
# Table name: ai_personas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :json 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 :boolean default(FALSE), not null
|
||||
# temperature :float
|
||||
# top_p :float
|
||||
# user_id :integer
|
||||
# mentionable :boolean default(FALSE), not null
|
||||
# default_llm :text
|
||||
# max_context_posts :integer
|
||||
# max_post_context_tokens :integer
|
||||
# max_context_tokens :integer
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :string default([]), not null, is an Array
|
||||
# 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
|
||||
#
|
||||
|
||||
@@ -5,48 +5,25 @@ class CompletionPrompt < ActiveRecord::Base
|
||||
self.ignored_columns = ["provider"]
|
||||
|
||||
TRANSLATE = -301
|
||||
GENERATE_TITLES = -307
|
||||
GENERATE_TITLES = -302
|
||||
PROOFREAD = -303
|
||||
MARKDOWN_TABLE = -304
|
||||
CUSTOM_PROMPT = -305
|
||||
EXPLAIN = -306
|
||||
ILLUSTRATE_POST = -308
|
||||
|
||||
enum :prompt_type, { text: 0, list: 1, diff: 2 }
|
||||
|
||||
validates :messages, length: { maximum: 20 }
|
||||
validate :each_message_length
|
||||
|
||||
after_commit { DiscourseAi::AiHelper::Assistant.clear_prompt_cache! }
|
||||
|
||||
def self.enabled_by_name(name)
|
||||
where(enabled: true).find_by(name: name)
|
||||
end
|
||||
|
||||
attr_accessor :custom_instruction
|
||||
|
||||
def messages_with_input(input)
|
||||
return unless input
|
||||
|
||||
user_input =
|
||||
if id == CUSTOM_PROMPT && custom_instruction.present?
|
||||
"#{custom_instruction}:\n#{input}"
|
||||
else
|
||||
input
|
||||
end
|
||||
|
||||
instructions = [messages_hash[:insts], messages_hash[:post_insts].to_s].join("\n")
|
||||
|
||||
prompt = DiscourseAi::Completions::Prompt.new(instructions)
|
||||
|
||||
messages_hash[:examples].to_a do |example_pair|
|
||||
prompt.push(type: :user, content: example_pair.first)
|
||||
prompt.push(type: :model, content: example_pair.second)
|
||||
end
|
||||
|
||||
prompt.push(type: :user, content: "<input>#{user_input}</input>")
|
||||
|
||||
prompt
|
||||
messages_hash.merge(input: "<input>#{input}</input")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -76,8 +53,6 @@ end
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# messages :jsonb
|
||||
# temperature :integer
|
||||
# stop_sequences :string is an Array
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -34,8 +34,7 @@ class ReviewableAiChatMessage < Reviewable
|
||||
|
||||
def build_actions(actions, guardian, args)
|
||||
return unless pending?
|
||||
|
||||
return build_action(actions, :ignore, icon: "external-link-alt") if chat_message.blank?
|
||||
return if chat_message.blank?
|
||||
|
||||
agree =
|
||||
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiToolSerializer < ApplicationSerializer
|
||||
attributes :options, :id, :name, :help
|
||||
|
||||
def include_options?
|
||||
object.accepted_options.present?
|
||||
end
|
||||
|
||||
def id
|
||||
object.to_s.split("::").last
|
||||
end
|
||||
|
||||
def name
|
||||
object.name.humanize.titleize
|
||||
end
|
||||
|
||||
def help
|
||||
object.help
|
||||
end
|
||||
|
||||
def options
|
||||
options = {}
|
||||
object.accepted_options.each do |option|
|
||||
options[option.name] = {
|
||||
name: option.localized_name,
|
||||
description: option.localized_description,
|
||||
type: option.type,
|
||||
}
|
||||
end
|
||||
options
|
||||
end
|
||||
end
|
||||
@@ -11,15 +11,7 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||
:priority,
|
||||
:commands,
|
||||
:system_prompt,
|
||||
:allowed_group_ids,
|
||||
:temperature,
|
||||
:top_p,
|
||||
:mentionable,
|
||||
:default_llm,
|
||||
:user_id,
|
||||
:max_context_posts
|
||||
|
||||
has_one :user, serializer: BasicUserSerializer, embed: :object
|
||||
:allowed_group_ids
|
||||
|
||||
def name
|
||||
object.class_instance.name
|
||||
|
||||
@@ -10,7 +10,7 @@ class ReviewableAiChatMessageSerializer < ReviewableSerializer
|
||||
has_one :chat_channel, serializer: AiChatChannelSerializer, root: false, embed: :objects
|
||||
|
||||
def chat_channel
|
||||
object.chat_message&.chat_channel
|
||||
object.chat_message.chat_channel
|
||||
end
|
||||
|
||||
def target_id
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<div id="related-topics" class="more-topics__list " role="complementary" aria-labelledby="related-topics-title">
|
||||
<h3 id="related-topics-title" class="more-topics__list-title">
|
||||
<%= t 'js.discourse_ai.related_topics.title' %>
|
||||
</h3>
|
||||
<div class="topic-list-container" itemscope itemtype='http://schema.org/ItemList'>
|
||||
<meta itemprop='itemListOrder' content='http://schema.org/ItemListOrderDescending'>
|
||||
<table class='topic-list'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t 'js.topic.title' %></th>
|
||||
<th></th>
|
||||
<th class="replies"><%= t 'js.replies' %></th>
|
||||
<th class="views"><%= t 'js.views' %></th>
|
||||
<th><%= t 'js.activity' %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @list.each_with_index do |topic, idx| %>
|
||||
<tr class="topic-list-item" id="topic-list-item-<%= topic.id %>">
|
||||
<td class="main-link" itemprop='itemListElement' itemscope itemtype='http://schema.org/ListItem'>
|
||||
<meta itemprop='position' content='<%= idx + 1 %>'>
|
||||
<span class="link-top-line">
|
||||
<a itemprop='url' href='<%= topic.url %>' class='title raw-link raw-topic-link'><%= topic.title %></a>
|
||||
</span>
|
||||
<div class="link-bottom-line">
|
||||
<% if (!@category || @category.has_children?) && topic.category && !topic.category.uncategorized? %>
|
||||
<a href='<%= topic.category.url %>' class='badge-wrapper bullet'>
|
||||
<span class='badge-category-bg' style='background-color: #<%= topic.category.color %>'></span>
|
||||
<span class='badge-category clear-badge'>
|
||||
<span class='category-name'><%= topic.category.name %></span>
|
||||
</span>
|
||||
</a>
|
||||
<% end %>
|
||||
<% if tags = topic.visible_tags(guardian) %>
|
||||
<div class="discourse-tags">
|
||||
<% tags.each_with_index do |tag, index| %>
|
||||
<a href='<%= tag.full_url %>' class='discourse-tag'><%= tag.name %></a>
|
||||
<% if index < tags.size - 1 %>, <% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="replies">
|
||||
<span class='posts' title='<%= t 'posts' %>'><%= topic.posts_count - 1 %></span>
|
||||
</td>
|
||||
<td class="views">
|
||||
<span class='views' title='<%= t 'views' %>'><%= topic.views %></span>
|
||||
</td>
|
||||
<td>
|
||||
<%= I18n.l(topic.last_posted_at || topic.created_at, format: :date_only) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,9 +4,9 @@ export default {
|
||||
path: "/plugins",
|
||||
|
||||
map() {
|
||||
this.route("discourse-ai", function () {
|
||||
this.route("ai-personas", function () {
|
||||
this.route("new");
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,8 @@ export default class Adapter extends RestAdapter {
|
||||
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);
|
||||
pathFor() {
|
||||
return super.pathFor(...arguments) + ".json";
|
||||
}
|
||||
|
||||
apiNameFor() {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
const ATTRIBUTES = [
|
||||
@@ -11,102 +9,17 @@ const ATTRIBUTES = [
|
||||
"enabled",
|
||||
"system",
|
||||
"priority",
|
||||
"top_p",
|
||||
"temperature",
|
||||
"user_id",
|
||||
"mentionable",
|
||||
"default_llm",
|
||||
"user",
|
||||
"max_context_posts",
|
||||
];
|
||||
|
||||
class CommandOption {
|
||||
@tracked value = null;
|
||||
}
|
||||
|
||||
export default class AiPersona extends RestModel {
|
||||
// this code is here to convert the wire schema to easier to work with object
|
||||
// on the wire we pass in/out commands as an Array.
|
||||
// [[CommandName, {option1: value, option2: value}], CommandName2, CommandName3]
|
||||
// So we rework this into a "commands" property and nested commandOptions
|
||||
init(properties) {
|
||||
if (properties.commands) {
|
||||
properties.commands = properties.commands.map((command) => {
|
||||
if (typeof command === "string") {
|
||||
return command;
|
||||
} else {
|
||||
let [commandId, options] = command;
|
||||
for (let optionId in options) {
|
||||
if (!options.hasOwnProperty(optionId)) {
|
||||
continue;
|
||||
}
|
||||
this.getCommandOption(commandId, optionId).value =
|
||||
options[optionId];
|
||||
}
|
||||
return commandId;
|
||||
}
|
||||
});
|
||||
}
|
||||
super.init(properties);
|
||||
this.commands = properties.commands;
|
||||
}
|
||||
|
||||
async createUser() {
|
||||
const result = await ajax(
|
||||
`/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`,
|
||||
{
|
||||
type: "POST",
|
||||
}
|
||||
);
|
||||
this.user = result.user;
|
||||
this.user_id = this.user.id;
|
||||
return this.user;
|
||||
}
|
||||
|
||||
getCommandOption(commandId, optionId) {
|
||||
this.commandOptions ||= {};
|
||||
this.commandOptions[commandId] ||= {};
|
||||
return (this.commandOptions[commandId][optionId] ||= new CommandOption());
|
||||
}
|
||||
|
||||
populateCommandOptions(attrs) {
|
||||
if (!attrs.commands) {
|
||||
return;
|
||||
}
|
||||
let commandsWithOptions = [];
|
||||
attrs.commands.forEach((commandId) => {
|
||||
if (typeof commandId !== "string") {
|
||||
commandId = commandId[0];
|
||||
}
|
||||
if (this.commandOptions && this.commandOptions[commandId]) {
|
||||
let options = this.commandOptions[commandId];
|
||||
let optionsWithValues = {};
|
||||
for (let optionId in options) {
|
||||
if (!options.hasOwnProperty(optionId)) {
|
||||
continue;
|
||||
}
|
||||
let option = options[optionId];
|
||||
optionsWithValues[optionId] = option.value;
|
||||
}
|
||||
commandsWithOptions.push([commandId, optionsWithValues]);
|
||||
} else {
|
||||
commandsWithOptions.push(commandId);
|
||||
}
|
||||
});
|
||||
attrs.commands = commandsWithOptions;
|
||||
}
|
||||
|
||||
updateProperties() {
|
||||
let attrs = this.getProperties(ATTRIBUTES);
|
||||
attrs.id = this.id;
|
||||
this.populateCommandOptions(attrs);
|
||||
return attrs;
|
||||
}
|
||||
|
||||
createProperties() {
|
||||
let attrs = this.getProperties(ATTRIBUTES);
|
||||
this.populateCommandOptions(attrs);
|
||||
return attrs;
|
||||
return this.getProperties(ATTRIBUTES);
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<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}}
|
||||
class="btn-flat ai-bot-available-bot-content"
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export default MultiSelectComponent.extend({
|
||||
}),
|
||||
|
||||
content: computed(function () {
|
||||
return this.commands;
|
||||
return this.attrs.commands.value;
|
||||
}),
|
||||
|
||||
value: "",
|
||||
@@ -1,46 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
|
||||
export default class AiHelperCustomPrompt extends Component {
|
||||
@tracked _customPromptInput;
|
||||
|
||||
@action
|
||||
setupCustomPrompt() {
|
||||
this._customPromptInput = document.querySelector(
|
||||
".ai-custom-prompt__input"
|
||||
);
|
||||
this._customPromptInput.focus();
|
||||
}
|
||||
|
||||
@action
|
||||
sendInput() {
|
||||
return this.args.submit(this.args.promptArgs);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-custom-prompt" {{didInsert this.setupCustomPrompt}}>
|
||||
<Input
|
||||
@value={{@value}}
|
||||
placeholder={{i18n
|
||||
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
|
||||
}}
|
||||
class="ai-custom-prompt__input"
|
||||
@enter={{this.sendInput}}
|
||||
/>
|
||||
|
||||
<DButton
|
||||
@icon="discourse-sparkles"
|
||||
@action={{fn @submit @promptArgs}}
|
||||
@disabled={{not @value.length}}
|
||||
class="ai-custom-prompt__submit btn-primary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import DButton from "discourse/components/d-button";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
|
||||
const AiHelperLoading = <template>
|
||||
<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={{@cancel}}
|
||||
class="btn-flat cancel-request"
|
||||
/>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default AiHelperLoading;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { computed, observer } from "@ember/object";
|
||||
import I18n from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
|
||||
export default ComboBox.extend({
|
||||
_modelDisabledChanged: observer("attrs.disabled", function () {
|
||||
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
|
||||
}),
|
||||
|
||||
content: computed(function () {
|
||||
return [
|
||||
{
|
||||
id: "blank",
|
||||
name: I18n.t("discourse_ai.ai_persona.no_llm_selected"),
|
||||
},
|
||||
].concat(this.llms);
|
||||
}),
|
||||
|
||||
selectKitOptions: {
|
||||
filterable: true,
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Input } from "@ember/component";
|
||||
|
||||
const AiPersonaCommandOptionEditor = <template>
|
||||
<div class="control-group ai-persona-command-option-editor">
|
||||
<label>
|
||||
{{@option.name}}
|
||||
</label>
|
||||
<div class="">
|
||||
<Input @value={{@option.value.value}} />
|
||||
</div>
|
||||
<div class="ai-persona-command-option-editor__instructions">
|
||||
{{@option.description}}
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default AiPersonaCommandOptionEditor;
|
||||
@@ -1,81 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import I18n from "discourse-i18n";
|
||||
import AiPersonaCommandOptionEditor from "./ai-persona-command-option-editor";
|
||||
|
||||
export default class AiPersonaCommandOptions extends Component {
|
||||
get showCommandOptions() {
|
||||
const allCommands = this.args.allCommands;
|
||||
if (!allCommands) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.commandNames.any(
|
||||
(command) => allCommands.find((c) => c.id === command)?.options
|
||||
);
|
||||
}
|
||||
|
||||
get commandNames() {
|
||||
if (!this.args.commands) {
|
||||
return [];
|
||||
}
|
||||
return this.args.commands.map((command) => {
|
||||
if (typeof command === "string") {
|
||||
return command;
|
||||
} else {
|
||||
return command[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get commandOptions() {
|
||||
if (!this.args.commands) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allCommands = this.args.allCommands;
|
||||
if (!allCommands) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = [];
|
||||
this.commandNames.forEach((commandId) => {
|
||||
const command = allCommands.find((c) => c.id === commandId);
|
||||
|
||||
const commandName = command?.name;
|
||||
const commandOptions = command?.options;
|
||||
|
||||
if (commandOptions) {
|
||||
const mappedOptions = Object.keys(commandOptions).map((key) => {
|
||||
const value = this.args.persona.getCommandOption(commandId, key);
|
||||
return Object.assign({}, commandOptions[key], { id: key, value });
|
||||
});
|
||||
|
||||
options.push({ commandName, options: mappedOptions });
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.showCommandOptions}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.command_options"}}</label>
|
||||
<div>
|
||||
{{#each this.commandOptions as |commandOption|}}
|
||||
<div class="ai-persona-editor__command-options">
|
||||
<div class="ai-persona-editor__command-options-name">
|
||||
{{commandOption.commandName}}
|
||||
</div>
|
||||
<div class="ai-persona-editor__command-option-options">
|
||||
{{#each commandOption.options as |option|}}
|
||||
<AiPersonaCommandOptionEditor @option={{option}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -5,22 +5,18 @@ 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 { LinkTo } from "@ember/routing";
|
||||
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 Avatar from "discourse/helpers/bound-avatar-template";
|
||||
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 AdminUser from "admin/models/admin-user";
|
||||
import GroupChooser from "select-kit/components/group-chooser";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import AiCommandSelector from "./ai-command-selector";
|
||||
import AiLlmSelector from "./ai-llm-selector";
|
||||
import AiPersonaCommandOptions from "./ai-persona-command-options";
|
||||
|
||||
export default class PersonaEditor extends Component {
|
||||
@service router;
|
||||
@@ -77,30 +73,6 @@ export default class PersonaEditor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
get showTemperature() {
|
||||
return this.editingModel?.temperature || !this.editingModel?.system;
|
||||
}
|
||||
|
||||
get showTopP() {
|
||||
return this.editingModel?.top_p || !this.editingModel?.system;
|
||||
}
|
||||
|
||||
get adminUser() {
|
||||
return AdminUser.create(this.editingModel?.user);
|
||||
}
|
||||
|
||||
get mappedDefaultLlm() {
|
||||
return this.editingModel?.default_llm || "blank";
|
||||
}
|
||||
|
||||
set mappedDefaultLlm(value) {
|
||||
if (value === "blank") {
|
||||
this.editingModel.default_llm = null;
|
||||
} else {
|
||||
this.editingModel.default_llm = value;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
delete() {
|
||||
return this.dialog.confirm({
|
||||
@@ -123,42 +95,26 @@ export default class PersonaEditor extends Component {
|
||||
|
||||
@action
|
||||
async toggleEnabled() {
|
||||
await this.toggleField("enabled");
|
||||
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() {
|
||||
await this.toggleField("priority", true);
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleMentionable() {
|
||||
await this.toggleField("mentionable");
|
||||
}
|
||||
|
||||
@action
|
||||
async createUser() {
|
||||
try {
|
||||
let user = await this.args.model.createUser();
|
||||
this.editingModel.set("user", user);
|
||||
this.editingModel.set("user_id", user.id);
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleField(field, sortPersonas) {
|
||||
this.args.model.set(field, !this.args.model[field]);
|
||||
this.editingModel.set(field, this.args.model[field]);
|
||||
this.args.model.set("priority", !this.args.model.priority);
|
||||
this.editingModel.set("priority", this.args.model.priority);
|
||||
if (!this.args.model.isNew) {
|
||||
try {
|
||||
const args = {};
|
||||
args[field] = this.args.model[field];
|
||||
await this.args.model.update({ priority: this.args.model.priority });
|
||||
|
||||
await this.args.model.update(args);
|
||||
if (sortPersonas) {
|
||||
this.#sortPersonas();
|
||||
}
|
||||
this.#sortPersonas();
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
@@ -203,23 +159,9 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.editingModel.user}}
|
||||
<div class="control-group ai-persona-editor__mentionable">
|
||||
<DToggleSwitch
|
||||
class="ai-persona-editor__mentionable_toggle"
|
||||
@state={{@model.mentionable}}
|
||||
@label="discourse_ai.ai_persona.mentionable"
|
||||
{{on "click" this.toggleMentionable}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.mentionable_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
||||
<Input
|
||||
@@ -237,46 +179,6 @@ export default class PersonaEditor extends Component {
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.default_llm"}}</label>
|
||||
<AiLlmSelector
|
||||
class="ai-persona-editor__llms"
|
||||
@value={{this.mappedDefaultLlm}}
|
||||
@llms={{@personas.resultSetMeta.llms}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.default_llm_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#unless @model.isNew}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.user"}}</label>
|
||||
{{#if this.editingModel.user}}
|
||||
<a
|
||||
class="avatar"
|
||||
href={{this.editingModel.user.path}}
|
||||
data-user-card={{this.editingModel.user.username}}
|
||||
>
|
||||
{{Avatar this.editingModel.user.avatar_template "small"}}
|
||||
</a>
|
||||
<LinkTo @route="adminUser" @model={{this.adminUser}}>
|
||||
{{this.editingModel.user.username}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<DButton
|
||||
@action={{this.createUser}}
|
||||
class="ai-persona-editor__create-user"
|
||||
>
|
||||
{{I18n.t "discourse_ai.ai_persona.create_user"}}
|
||||
</DButton>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.create_user_help"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
|
||||
<AiCommandSelector
|
||||
@@ -286,13 +188,6 @@ export default class PersonaEditor extends Component {
|
||||
@commands={{@personas.resultSetMeta.commands}}
|
||||
/>
|
||||
</div>
|
||||
{{#unless this.editingModel.system}}
|
||||
<AiPersonaCommandOptions
|
||||
@persona={{this.editingModel}}
|
||||
@commands={{this.editingModel.commands}}
|
||||
@allCommands={{@personas.resultSetMeta.commands}}
|
||||
/>
|
||||
{{/unless}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
|
||||
<GroupChooser
|
||||
@@ -311,48 +206,6 @@ export default class PersonaEditor extends Component {
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.max_context_posts"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
class="ai-persona-editor__max_context_posts"
|
||||
@value={{this.editingModel.max_context_posts}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.max_context_posts_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.showTemperature}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
class="ai-persona-editor__temperature"
|
||||
@value={{this.editingModel.temperature}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.temperature_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.showTopP}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.top_p"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
class="ai-persona-editor__top_p"
|
||||
@value={{this.editingModel.top_p}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.top_p_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="control-group ai-persona-editor__action_panel">
|
||||
<DButton
|
||||
class="btn-primary ai-persona-editor__save"
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import categoryBadge from "discourse/helpers/category-badge";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
import eq from "truth-helpers/helpers/eq";
|
||||
|
||||
export default class AiSplitTopicSuggester extends Component {
|
||||
@service site;
|
||||
@service menu;
|
||||
|
||||
@tracked suggestions = [];
|
||||
@tracked loading = false;
|
||||
@tracked icon = "discourse-sparkles";
|
||||
|
||||
SUGGESTION_TYPES = {
|
||||
title: "suggest_title",
|
||||
category: "suggest_category",
|
||||
tag: "suggest_tags",
|
||||
};
|
||||
|
||||
get input() {
|
||||
return this.args.selectedPosts.map((item) => item.cooked).join("\n");
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
return this.loading || this.suggestions.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
loadSuggestions() {
|
||||
if (this.loading || this.suggestions.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
|
||||
method: "POST",
|
||||
data: { text: this.input },
|
||||
})
|
||||
.then((result) => {
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.title) {
|
||||
this.suggestions = result.suggestions;
|
||||
} else if (this.args.mode === this.SUGGESTION_TYPES.category) {
|
||||
const suggestions = result.assistant.map((s) => s.name);
|
||||
const suggestedCategories = this.site.categories.filter((item) =>
|
||||
suggestions.includes(item.name.toLowerCase())
|
||||
);
|
||||
this.suggestions = suggestedCategories;
|
||||
} else {
|
||||
this.suggestions = result.assistant.map((s) => s.name);
|
||||
}
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
applySuggestion(suggestion, menu) {
|
||||
if (!this.args.mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.title) {
|
||||
this.args.updateAction(suggestion);
|
||||
return menu.close();
|
||||
}
|
||||
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.category) {
|
||||
this.args.updateAction(suggestion.id);
|
||||
return menu.close();
|
||||
}
|
||||
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.tag) {
|
||||
if (this.args.currentValue) {
|
||||
if (Array.isArray(this.args.currentValue)) {
|
||||
const updatedTags = [...this.args.currentValue, suggestion];
|
||||
this.args.updateAction([...new Set(updatedTags)]);
|
||||
} else {
|
||||
const updatedTags = [this.args.currentValue, suggestion];
|
||||
this.args.updateAction([...new Set(updatedTags)]);
|
||||
}
|
||||
} else {
|
||||
this.args.updateAction(suggestion);
|
||||
}
|
||||
return menu.close();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.loading}}
|
||||
{{!
|
||||
Dynamically changing @icon of DMenu
|
||||
causes it to rerender after load and
|
||||
close the menu once data is loaded.
|
||||
This workaround mimics an icon change of
|
||||
the button by adding an overlapping
|
||||
disabled button while loading}}
|
||||
<DButton
|
||||
class="ai-split-topic-loading-placeholder"
|
||||
@disabled={{true}}
|
||||
@icon="spinner"
|
||||
/>
|
||||
{{/if}}
|
||||
<DMenu
|
||||
@icon="discourse-sparkles"
|
||||
@interactive={{true}}
|
||||
@identifier="ai-split-topic-suggestion-menu"
|
||||
class="ai-split-topic-suggestion-button"
|
||||
data-suggestion-mode={{@mode}}
|
||||
{{on "click" this.loadSuggestions}}
|
||||
as |menu|
|
||||
>
|
||||
<ul class="ai-split-topic-suggestion__results">
|
||||
{{#unless this.loading}}
|
||||
{{#each this.suggestions as |suggestion index|}}
|
||||
{{#if (eq @mode "suggest_category")}}
|
||||
<li
|
||||
data-name={{suggestion.name}}
|
||||
data-value={{suggestion.id}}
|
||||
class="ai-split-topic-suggestion__category-result"
|
||||
role="button"
|
||||
{{on "click" (fn this.applySuggestion suggestion menu)}}
|
||||
>
|
||||
{{categoryBadge suggestion}}
|
||||
</li>
|
||||
{{else}}
|
||||
<li data-name={{suggestion}} data-value={{index}}>
|
||||
<DButton
|
||||
@translatedLabel={{suggestion}}
|
||||
@action={{fn this.applySuggestion suggestion menu}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/unless}}
|
||||
</ul>
|
||||
</DMenu>
|
||||
</template>
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { inject as service } from "@ember/service";
|
||||
@@ -14,14 +13,12 @@ export default class AISuggestionDropdown extends Component {
|
||||
@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",
|
||||
@@ -56,13 +53,9 @@ export default class AISuggestionDropdown extends Component {
|
||||
@action
|
||||
applyClasses() {
|
||||
if (this.showAIButton) {
|
||||
document
|
||||
.querySelector(".composer-fields")
|
||||
?.classList.add("showing-ai-suggestions");
|
||||
document.querySelector(".composer-fields")?.classList.add("showing-ai-suggestions");
|
||||
} else {
|
||||
document
|
||||
.querySelector(".composer-fields")
|
||||
?.classList.remove("showing-ai-suggestions");
|
||||
document.querySelector(".composer-fields")?.classList.remove("showing-ai-suggestions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,18 +133,14 @@ export default class AISuggestionDropdown extends Component {
|
||||
this.showMenu = true;
|
||||
|
||||
if (this.args.mode === "suggest_category") {
|
||||
document
|
||||
.querySelector(".category-input")
|
||||
?.classList.add("showing-ai-suggestion-menu");
|
||||
document.querySelector(".category-input")?.classList.add("showing-ai-suggestion-menu");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#closeMenu() {
|
||||
if (this.showMenu && this.args.mode === "suggest_category") {
|
||||
document
|
||||
.querySelector(".category-input")
|
||||
?.classList.remove("showing-ai-suggestion-menu");
|
||||
document.querySelector(".category-input")?.classList.remove("showing-ai-suggestion-menu");
|
||||
}
|
||||
|
||||
this.suggestIcon = "discourse-sparkles";
|
||||
@@ -216,11 +205,11 @@ 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}}
|
||||
class="suggestion-button {{if this.loading 'is-loading'}}"
|
||||
...attributes
|
||||
/>
|
||||
{{/if}}
|
||||
@@ -237,9 +226,10 @@ export default class AISuggestionDropdown extends Component {
|
||||
{{#each this.generatedSuggestions as |suggestion index|}}
|
||||
<li data-name={{suggestion}} data-value={{index}}>
|
||||
<DButton
|
||||
@class="popup-menu-btn"
|
||||
@translatedLabel={{suggestion}}
|
||||
@action={{fn this.applySuggestion suggestion}}
|
||||
class="popup-menu-btn"
|
||||
@action={{this.applySuggestion}}
|
||||
@actionParam={{suggestion}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user