Compare commits

..
Author SHA1 Message Date
Keegan George e7a3a50d86 DEV: Decorate boosted and AI results 2023-11-27 11:01:14 -08:00
465 changed files with 8056 additions and 110709 deletions
-1
View File
@@ -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
View File
@@ -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
-26
View File
@@ -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
-35
View File
@@ -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
-132
View File
@@ -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
-9
View File
@@ -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
-2
View File
@@ -5,8 +5,6 @@ class AiApiAuditLog < ActiveRecord::Base
OpenAI = 1
Anthropic = 2
HuggingFaceTextGeneration = 3
Gemini = 4
Vllm = 5
end
end
+19 -159
View File
@@ -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
#
+2 -27
View File
@@ -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
#
+1 -2
View File
@@ -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")
-33
View File
@@ -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
-58
View File
@@ -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 %>,&nbsp;<% 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>
@@ -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