Compare commits

..
Author SHA1 Message Date
Jordan Vidrine 47fdb1acbf FIX: Adjust for overlap in AI buttons 2023-11-04 12:48:46 -05:00
509 changed files with 8956 additions and 114966 deletions
-2
View File
@@ -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
-21
View File
@@ -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
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
@@ -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
@@ -1,12 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class DashboardController < ::Admin::StaffController
requires_plugin DiscourseAi::PLUGIN_NAME
def sentiment
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
-26
View File
@@ -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
-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
File diff suppressed because it is too large Load Diff
+20 -41
View File
@@ -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
#
+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
@@ -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
-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>
@@ -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,10 +0,0 @@
export default {
resource: "admin.dashboard",
path: "/dashboard",
map() {
this.route("admin.dashboardSentiment", {
path: "/dashboard/sentiment",
resetNamespace: true,
});
},
};
@@ -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;

Some files were not shown because too many files have changed in this diff Show More