Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eceb6ef06 |
@@ -1,5 +1,3 @@
|
||||
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
|
||||
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
||||
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
|
||||
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
|
||||
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
app/assets/stylesheets/vendor/
|
||||
documentation/
|
||||
package.json
|
||||
config/locales/**/*.yml
|
||||
!config/locales/**/*.en*.yml
|
||||
script/import_scripts/**/*.yml
|
||||
|
||||
plugins/**/lib/javascripts/locale
|
||||
public/
|
||||
!/app/assets/javascripts/discourse/public
|
||||
vendor/
|
||||
app/assets/javascripts/discourse/tests/fixtures
|
||||
spec/
|
||||
node_modules/
|
||||
dist/
|
||||
tmp/
|
||||
|
||||
**/*.rb
|
||||
**/*.html
|
||||
**/*.json
|
||||
**/*.md
|
||||
+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
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
before_action :find_ai_persona, only: %i[show update destroy create_user]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
AiPersona.ordered.map do |persona|
|
||||
# we use a special serializer here cause names and descriptions are
|
||||
# localized for system personas
|
||||
LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
end
|
||||
tools =
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
|
||||
AiToolSerializer.new(tool, root: false)
|
||||
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 } }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
|
||||
end
|
||||
|
||||
def create
|
||||
ai_persona = AiPersona.new(ai_persona_params)
|
||||
if ai_persona.save
|
||||
render json: { ai_persona: ai_persona }, status: :created
|
||||
else
|
||||
render_json_error ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def 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
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @ai_persona.destroy
|
||||
head :no_content
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_ai_persona
|
||||
@ai_persona = AiPersona.find(params[:id])
|
||||
end
|
||||
|
||||
def ai_persona_params
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,51 +8,60 @@ 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::LlmPrompt.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(
|
||||
prompt,
|
||||
input,
|
||||
current_user,
|
||||
),
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, params),
|
||||
status: 200
|
||||
end
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
def suggest_title
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
prompt = CompletionPrompt.enabled_by_name("generate_titles")
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt
|
||||
llm_prompt =
|
||||
DiscourseAi::AiHelper::LlmPrompt
|
||||
.new
|
||||
.available_prompts(name_filter: "generate_titles")
|
||||
.first
|
||||
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
|
||||
prompt,
|
||||
input,
|
||||
current_user,
|
||||
input_hash,
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
@@ -77,7 +86,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)
|
||||
|
||||
@@ -87,20 +98,21 @@ module DiscourseAi
|
||||
|
||||
def explain
|
||||
post_id = get_post_param!
|
||||
term_to_explain = get_text_param!
|
||||
post = Post.includes(:topic).find_by(id: post_id)
|
||||
text = get_text_param!
|
||||
post = Post.find_by(id: post_id)
|
||||
|
||||
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(
|
||||
{ text: text },
|
||||
current_user,
|
||||
post: post,
|
||||
).explain,
|
||||
status: 200
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class CreateAiReply < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args)
|
||||
return unless bot_user = User.find_by(id: args[:bot_user_id])
|
||||
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
persona_id = args[:persona_id]
|
||||
|
||||
begin
|
||||
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
|
||||
raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
|
||||
|
||||
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
|
||||
|
||||
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
|
||||
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
|
||||
Rails.logger.warn(
|
||||
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
|
||||
+56
-254
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CompletionPrompt < ActiveRecord::Base
|
||||
# TODO(roman): Remove may 2024.
|
||||
self.ignored_columns = ["provider"]
|
||||
|
||||
TRANSLATE = -301
|
||||
GENERATE_TITLES = -307
|
||||
PROOFREAD = -303
|
||||
MARKDOWN_TABLE = -304
|
||||
CUSTOM_PROMPT = -305
|
||||
EXPLAIN = -306
|
||||
ILLUSTRATE_POST = -308
|
||||
# TODO(roman): Remove sept 2023.
|
||||
self.ignored_columns = ["value"]
|
||||
|
||||
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 messages_with_user_input(user_input)
|
||||
return messages unless user_input.present?
|
||||
|
||||
def self.enabled_by_name(name)
|
||||
where(enabled: true).find_by(name: name)
|
||||
end
|
||||
|
||||
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}"
|
||||
if user_input[:custom_prompt].present?
|
||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||
when "huggingface"
|
||||
self.messages.each { |msg| msg.sub!("{{custom_prompt}}", user_input[:custom_prompt]) }
|
||||
else
|
||||
input
|
||||
self.messages.each do |msg|
|
||||
msg["content"].sub!("{{custom_prompt}}", user_input[:custom_prompt])
|
||||
end
|
||||
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
|
||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||
when "openai"
|
||||
self.messages << { role: "user", content: user_input[:text] }
|
||||
when "anthropic"
|
||||
self.messages << { "role" => "Input", "content" => "<input>#{user_input[:text]}</input>" }
|
||||
when "huggingface"
|
||||
self.messages.first.sub("{{user_input}}", user_input[:text])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messages_hash
|
||||
@messages_hash ||= messages.symbolize_keys!
|
||||
end
|
||||
|
||||
def each_message_length
|
||||
messages.each_with_index do |msg, idx|
|
||||
next if msg["content"].length <= 1000
|
||||
@@ -76,8 +56,7 @@ end
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# messages :jsonb
|
||||
# temperature :integer
|
||||
# stop_sequences :string is an Array
|
||||
# provider :text
|
||||
#
|
||||
# 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
|
||||
@@ -1,31 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||
root "ai_persona"
|
||||
|
||||
attributes :id,
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system,
|
||||
: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
|
||||
|
||||
def name
|
||||
object.class_instance.name
|
||||
end
|
||||
|
||||
def description
|
||||
object.class_instance.description
|
||||
end
|
||||
end
|
||||
@@ -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>
|
||||
@@ -1,14 +0,0 @@
|
||||
export default {
|
||||
resource: "admin.adminPlugins",
|
||||
|
||||
path: "/plugins",
|
||||
|
||||
map() {
|
||||
this.route("discourse-ai", function () {
|
||||
this.route("ai-personas", function () {
|
||||
this.route("new");
|
||||
this.route("show", { path: "/:id" });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class Adapter extends RestAdapter {
|
||||
jsonMode = true;
|
||||
|
||||
basePath() {
|
||||
return "/admin/plugins/discourse-ai/";
|
||||
}
|
||||
|
||||
pathFor(store, type, findArgs) {
|
||||
// removes underscores which are implemented in base
|
||||
let path =
|
||||
this.basePath(store, type, findArgs) +
|
||||
store.pluralize(this.apiNameFor(type));
|
||||
return this.appendQueryParams(path, findArgs);
|
||||
}
|
||||
|
||||
apiNameFor() {
|
||||
return "ai-persona";
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
const ATTRIBUTES = [
|
||||
"name",
|
||||
"description",
|
||||
"commands",
|
||||
"system_prompt",
|
||||
"allowed_group_ids",
|
||||
"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;
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
return AiPersona.create(this.createProperties());
|
||||
}
|
||||
}
|
||||
@@ -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,18 +0,0 @@
|
||||
import { computed, observer } from "@ember/object";
|
||||
import MultiSelectComponent from "select-kit/components/multi-select";
|
||||
|
||||
export default MultiSelectComponent.extend({
|
||||
_modelDisabledChanged: observer("attrs.disabled", function () {
|
||||
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
|
||||
}),
|
||||
|
||||
content: computed(function () {
|
||||
return this.commands;
|
||||
}),
|
||||
|
||||
value: "",
|
||||
|
||||
selectKitOptions: {
|
||||
filterable: true,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user