Compare commits

..
Author SHA1 Message Date
Martin Brennan bf8b3a4b40 DEV: Test if site setting can be added without translation 2024-07-10 13:55:19 +10:00
269 changed files with 3638 additions and 7759 deletions
+6 -4
View File
@@ -1,6 +1,8 @@
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1 < 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
< 3.3.0.beta1-dev: d94234d89f50733678a5c62809445198a9fda74b < 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
3.2.999: d94234d89f50733678a5c62809445198a9fda74b < 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df 3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
3.1.0.beta3: 057fbe1ce6
+1 -1
View File
@@ -32,7 +32,7 @@ GEM
rack (3.1.6) rack (3.1.6)
rainbow (3.1.1) rainbow (3.1.1)
regexp_parser (2.9.2) regexp_parser (2.9.2)
rexml (3.3.3) rexml (3.3.1)
strscan strscan
rubocop (1.64.1) rubocop (1.64.1)
json (~> 2.3) json (~> 2.3)
@@ -3,7 +3,6 @@ import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({ export default DiscourseRoute.extend({
async model() { async model() {
const record = this.store.createRecord("ai-llm"); const record = this.store.createRecord("ai-llm");
record.provider_params = {};
return record; return record;
}, },
@@ -4,9 +4,7 @@ export default DiscourseRoute.extend({
async model(params) { async model(params) {
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms"); const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
const id = parseInt(params.id, 10); const id = parseInt(params.id, 10);
const record = allLlms.findBy("id", id); return allLlms.findBy("id", id);
record.provider_params = record.provider_params || {};
return record;
}, },
setupController(controller, model) { setupController(controller, model) {
@@ -38,32 +38,24 @@ module DiscourseAi
llm_model.toggle_companion_user llm_model.toggle_companion_user
render json: { ai_persona: LlmModelSerializer.new(llm_model) }, status: :created render json: { ai_persona: LlmModelSerializer.new(llm_model) }, status: :created
else else
render_json_error llm_model render_json_error LlmModelSerializer.new(llm_model)
end end
end end
def update def update
llm_model = LlmModel.find(params[:id]) llm_model = LlmModel.find(params[:id])
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
end
if llm_model.update(ai_llm_params(updating: llm_model)) if llm_model.update(ai_llm_params(updating: llm_model))
llm_model.toggle_companion_user llm_model.toggle_companion_user
render json: LlmModelSerializer.new(llm_model) render json: LlmModelSerializer.new(llm_model)
else else
render_json_error llm_model render_json_error LlmModelSerializer.new(llm_model)
end end
end end
def destroy def destroy
llm_model = LlmModel.find(params[:id]) llm_model = LlmModel.find(params[:id])
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_delete_builtin"), status: 403)
end
in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model) in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model)
if !in_use_by.empty? if !in_use_by.empty?
@@ -105,8 +97,6 @@ module DiscourseAi
private private
def ai_llm_params(updating: nil) def ai_llm_params(updating: nil)
return {} if params[:ai_llm].blank?
permitted = permitted =
params.require(:ai_llm).permit( params.require(:ai_llm).permit(
:display_name, :display_name,
@@ -116,30 +106,20 @@ module DiscourseAi
:max_prompt_tokens, :max_prompt_tokens,
:api_key, :api_key,
:enabled_chat_bot, :enabled_chat_bot,
:vision_enabled,
) )
provider = updating ? updating.provider : permitted[:provider] provider = updating ? updating.provider : permitted[:provider]
permit_url = provider != LlmModel::BEDROCK_PROVIDER_NAME permit_url =
(updating && updating.url != LlmModel::RESERVED_VLLM_SRV_URL) ||
provider != LlmModel::BEDROCK_PROVIDER_NAME
new_url = params.dig(:ai_llm, :url) permitted[:url] = params.dig(:ai_llm, :url) if permit_url
permitted[:url] = new_url if permit_url && new_url
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym) extra_field_names = LlmModel.provider_params.dig(provider&.to_sym, :fields).to_a
if extra_field_names.present? received_prov_params = params.dig(:ai_llm, :provider_params)
received_prov_params = permitted[:provider_params] = received_prov_params.slice(
params.dig(:ai_llm, :provider_params)&.slice(*extra_field_names.keys) *extra_field_names,
).permit! if !extra_field_names.empty? && received_prov_params.present?
if received_prov_params.present?
received_prov_params.each do |pname, value|
if extra_field_names[pname.to_sym] == :checkbox
received_prov_params[pname] = ActiveModel::Type::Boolean.new.cast(value)
end
end
permitted[:provider_params] = received_prov_params.permit!
end
end
permitted permitted
end end
@@ -63,7 +63,7 @@ module DiscourseAi
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false) render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
else else
render_json_error @ai_persona render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
end end
end end
@@ -71,7 +71,7 @@ module DiscourseAi
if @ai_persona.destroy if @ai_persona.destroy
head :no_content head :no_content
else else
render_json_error @ai_persona render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
end end
end end
@@ -81,7 +81,7 @@ module DiscourseAi
:description, :description,
:script, :script,
:summary, :summary,
parameters: [:name, :type, :description, :required, enum: []], parameters: [:name, :type, :description, :required, :enum, enum_values: []],
) )
end end
end end
@@ -89,26 +89,18 @@ module DiscourseAi
end end
end end
def stream_suggestion def explain
post_id = get_post_param! post_id = get_post_param!
text = get_text_param! term_to_explain = get_text_param!
post = Post.includes(:topic).find_by(id: post_id) post = Post.includes(:topic).find_by(id: post_id)
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
raise Discourse::InvalidParameters.new(:post_id) unless post raise Discourse::InvalidParameters.new(:post_id) unless post
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
Jobs.enqueue( Jobs.enqueue(
:stream_post_helper, :stream_post_helper,
post_id: post.id, post_id: post.id,
user_id: current_user.id, user_id: current_user.id,
text: text, term_to_explain: term_to_explain,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
) )
render json: { success: true }, status: 200 render json: { success: true }, status: 200
@@ -165,13 +157,9 @@ module DiscourseAi
end end
def ensure_can_request_suggestions def ensure_can_request_suggestions
allowed_groups = if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
( raise Discourse::InvalidAccess
SiteSetting.composer_ai_helper_allowed_groups_map | end
SiteSetting.post_ai_helper_allowed_groups_map
)
raise Discourse::InvalidAccess if !current_user.in_any_groups?(allowed_groups)
end end
end end
end end
@@ -9,7 +9,6 @@ module DiscourseAi
def search def search
query = params[:q].to_s query = params[:q].to_s
skip_hyde = params[:hyde].downcase.to_s == "false" || params[:hyde].to_s == "0"
if query.length < SiteSetting.min_search_term_length if query.length < SiteSetting.min_search_term_length
raise Discourse::InvalidParameters.new(:q) raise Discourse::InvalidParameters.new(:q)
@@ -32,7 +31,7 @@ module DiscourseAi
hijack do hijack do
semantic_search semantic_search
.search_for_topics(query, _page = 1, hyde: !skip_hyde) .search_for_topics(query)
.each { |topic_post| grouped_results.add(topic_post) } .each { |topic_post| grouped_results.add(topic_post) }
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results) render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
@@ -40,9 +39,6 @@ module DiscourseAi
end end
def quick_search def quick_search
# this search function searches posts (vs: topics)
# it requires post embeddings and a reranker
# it will not perform a hyde expantion
query = params[:q].to_s query = params[:q].to_s
if query.length < SiteSetting.min_search_term_length if query.length < SiteSetting.min_search_term_length
@@ -14,32 +14,19 @@ module DiscourseAi
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user
opts = params.permit(:skip_age_check) opts = params.permit(:skip_age_check)
skip_age_check = opts[:skip_age_check] == "true"
if params[:stream] && current_user if params[:stream] && current_user
cached_summary = DiscourseAi::TopicSummarization.cached_summary(topic, current_user)
if cached_summary && !skip_age_check
render_serialized(cached_summary, AiTopicSummarySerializer)
return
end
Jobs.enqueue( Jobs.enqueue(
:stream_topic_ai_summary, :stream_topic_ai_summary,
topic_id: topic.id, topic_id: topic.id,
user_id: current_user.id, user_id: current_user.id,
skip_age_check: skip_age_check, opts: opts.as_json,
) )
render json: success_json render json: success_json
else else
hijack do hijack do
summary = summary = DiscourseAi::TopicSummarization.summarize(topic, current_user, opts)
DiscourseAi::TopicSummarization.summarize(
topic,
current_user,
skip_age_check: skip_age_check,
)
render_serialized(summary, AiTopicSummarySerializer) render_serialized(summary, AiTopicSummarySerializer)
end end
end end
+5 -13
View File
@@ -7,35 +7,27 @@ module Jobs
def execute(args) def execute(args)
return unless post = Post.includes(:topic).find_by(id: args[:post_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless user = User.find_by(id: args[:user_id]) return unless user = User.find_by(id: args[:user_id])
return unless args[:text] return unless args[:term_to_explain]
topic = post.topic topic = post.topic
reply_to = post.reply_to_post reply_to = post.reply_to_post
return unless user.guardian.can_see?(post) return unless user.guardian.can_see?(post)
prompt = CompletionPrompt.enabled_by_name(args[:prompt]) prompt = CompletionPrompt.enabled_by_name("explain")
if prompt.id == CompletionPrompt::CUSTOM_PROMPT input = <<~TEXT
prompt.custom_instruction = args[:custom_prompt] <term>#{args[:term_to_explain]}</term>
end
if prompt.name == "explain"
input = <<~TEXT
<term>#{args[:text]}</term>
<context>#{post.raw}</context> <context>#{post.raw}</context>
<topic>#{topic.title}</topic> <topic>#{topic.title}</topic>
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil} #{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
TEXT TEXT
else
input = args[:text]
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt( DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt, prompt,
input, input,
user, user,
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}", "/discourse-ai/ai-helper/explain/#{post.id}",
) )
end end
end end
+3 -3
View File
@@ -14,15 +14,15 @@ module Jobs
guardian = Guardian.new(user) guardian = Guardian.new(user)
return unless guardian.can_see?(topic) return unless guardian.can_see?(topic)
skip_age_check = !!args[:skip_age_check] opts = args[:opts] || {}
streamed_summary = +"" streamed_summary = +""
start = Time.now start = Time.now
summary = summary =
DiscourseAi::TopicSummarization DiscourseAi::TopicSummarization
.new(strategy, topic, user) .new(strategy)
.summarize(skip_age_check: skip_age_check) do |partial_summary| .summarize(topic, user, opts) do |partial_summary|
streamed_summary << partial_summary streamed_summary << partial_summary
# Throttle updates. # Throttle updates.
@@ -12,13 +12,6 @@ module ::Jobs
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE] return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
DiscourseAi::AiBot::Playground.new(bot).title_playground(post) DiscourseAi::AiBot::Playground.new(bot).title_playground(post)
publish_update(post.topic, { title: post.topic.title })
end
def publish_update(topic, payload)
allowed_users = topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish("/discourse-ai/ai-bot/topic/#{topic.id}", payload, user_ids: allowed_users)
end end
end end
end end
+38 -38
View File
@@ -2,41 +2,59 @@
class LlmModel < ActiveRecord::Base class LlmModel < ActiveRecord::Base
FIRST_BOT_USER_ID = -1200 FIRST_BOT_USER_ID = -1200
RESERVED_VLLM_SRV_URL = "https://vllm.shadowed-by-srv.invalid"
BEDROCK_PROVIDER_NAME = "aws_bedrock" BEDROCK_PROVIDER_NAME = "aws_bedrock"
belongs_to :user belongs_to :user
validates :display_name, presence: true, length: { maximum: 100 } validates :url, exclusion: { in: [RESERVED_VLLM_SRV_URL] }, if: :url_changed?
validates :tokenizer, presence: true, inclusion: DiscourseAi::Completions::Llm.tokenizer_names
validates :provider, presence: true, inclusion: DiscourseAi::Completions::Llm.provider_names def self.seed_srv_backed_model
validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME } srv = SiteSetting.ai_vllm_endpoint_srv
validates_presence_of :name, :api_key srv_model = find_by(url: RESERVED_VLLM_SRV_URL)
validates :max_prompt_tokens, numericality: { greater_than: 0 }
validate :required_provider_params if srv.present?
if srv_model.present?
current_key = SiteSetting.ai_vllm_api_key
srv_model.update!(api_key: current_key) if current_key != srv_model.api_key
else
record =
new(
display_name: "vLLM SRV LLM",
name: "mistralai/Mixtral-8x7B-Instruct-v0.1",
provider: "vllm",
tokenizer: "DiscourseAi::Tokenizer::MixtralTokenizer",
url: RESERVED_VLLM_SRV_URL,
max_prompt_tokens: 8000,
api_key: SiteSetting.ai_vllm_api_key,
)
record.save(validate: false) # Ignore reserved URL validation
end
else
# Clean up companion users
srv_model&.enabled_chat_bot = false
srv_model&.toggle_companion_user
srv_model&.destroy!
end
end
def self.provider_params def self.provider_params
{ {
aws_bedrock: { aws_bedrock: {
access_key_id: :text, url_editable: false,
region: :text, fields: %i[access_key_id region],
}, },
open_ai: { open_ai: {
organization: :text, url_editable: true,
}, fields: %i[organization],
hugging_face: {
disable_system_prompt: :checkbox,
},
vllm: {
disable_system_prompt: :checkbox,
},
ollama: {
disable_system_prompt: :checkbox,
}, },
} }
end end
def to_llm def to_llm
DiscourseAi::Completions::Llm.proxy("custom:#{id}") DiscourseAi::Completions::Llm.proxy_from_obj(self)
end end
def toggle_companion_user def toggle_companion_user
@@ -88,22 +106,6 @@ class LlmModel < ActiveRecord::Base
def lookup_custom_param(key) def lookup_custom_param(key)
provider_params&.dig(key) provider_params&.dig(key)
end end
def seeded?
id < 0
end
private
def required_provider_params
return if provider != BEDROCK_PROVIDER_NAME
%w[access_key_id region].each do |field|
if lookup_custom_param(field).blank?
errors.add(:base, I18n.t("discourse_ai.llm_models.missing_provider_param", param: field))
end
end
end
end end
# == Schema Information # == Schema Information
@@ -122,6 +124,4 @@ end
# api_key :string # api_key :string
# user_id :integer # user_id :integer
# enabled_chat_bot :boolean default(FALSE), not null # enabled_chat_bot :boolean default(FALSE), not null
# provider_params :jsonb
# vision_enabled :boolean default(FALSE), not null
# #
@@ -1,12 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class AiTopicSummarySerializer < ApplicationSerializer class AiTopicSummarySerializer < ApplicationSerializer
attributes :summarized_text, attributes :summarized_text, :algorithm, :outdated, :can_regenerate, :new_posts_since_summary
:algorithm,
:outdated,
:can_regenerate,
:new_posts_since_summary,
:updated_at
def can_regenerate def can_regenerate
scope.can_request_summary? scope.can_request_summary?
+4 -17
View File
@@ -12,25 +12,12 @@ class LlmModelSerializer < ApplicationSerializer
:api_key, :api_key,
:url, :url,
:enabled_chat_bot, :enabled_chat_bot,
:provider_params, :shadowed_by_srv,
:vision_enabled, :provider_params
:used_by
has_one :user, serializer: BasicUserSerializer, embed: :object has_one :user, serializer: BasicUserSerializer, embed: :object
def used_by def shadowed_by_srv
DiscourseAi::Configuration::LlmValidator.new.modules_using(object) object.url == LlmModel::RESERVED_VLLM_SRV_URL
end
def api_key
object.seeded? ? "********" : object.api_key
end
def url
object.seeded? ? "********" : object.url
end
def provider
object.seeded? ? "CDCK" : object.provider
end end
end end
@@ -2,36 +2,33 @@
module DiscourseAi module DiscourseAi
class TopicSummarization class TopicSummarization
def self.summarize(topic, user, skip_age_check: false, &on_partial_blk) def self.summarize(topic, user, opts = {}, &on_partial_blk)
new(DiscourseAi::Summarization.default_strategy, topic, user).summarize( new(DiscourseAi::Summarization.default_strategy).summarize(topic, user, opts, &on_partial_blk)
skip_age_check: skip_age_check,
&on_partial_blk
)
end end
def self.cached_summary(topic, user) def initialize(strategy)
new(DiscourseAi::Summarization.default_strategy, topic, user).cached_summary
end
def initialize(strategy, topic, user)
@strategy = strategy @strategy = strategy
@topic = topic
@user = user
end end
attr_reader :strategy, :topic, :user def summarize(topic, user, opts = {}, &on_partial_blk)
existing_summary = AiSummary.find_by(target: topic)
def cached_summary
existing_summary
end
def summarize(skip_age_check: false, &on_partial_blk)
# Existing summary shouldn't be nil in this scenario because the controller checks its existence. # Existing summary shouldn't be nil in this scenario because the controller checks its existence.
return if !user && !existing_summary return if !user && !existing_summary
return existing_summary if use_cached?(skip_age_check) targets_data = summary_targets(topic).pluck(:post_number, :raw, :username)
delete_cached_summaries! if existing_summary current_topic_sha = build_sha(targets_data.map(&:first))
can_summarize = Guardian.new(user).can_request_summary?
if use_cached?(existing_summary, can_summarize, current_topic_sha, !!opts[:skip_age_check])
# It's important that we signal a cached summary is outdated
existing_summary.mark_as_outdated if new_targets?(existing_summary, current_topic_sha)
return existing_summary
end
delete_cached_summaries_of(topic) if existing_summary
content = { content = {
resource_path: "#{Discourse.base_path}/t/-/#{topic.id}", resource_path: "#{Discourse.base_path}/t/-/#{topic.id}",
@@ -39,7 +36,7 @@ module DiscourseAi
contents: [], contents: [],
} }
summary_targets_data.map do |(pn, raw, username)| targets_data.map do |(pn, raw, username)|
raw_text = raw raw_text = raw
if pn == 1 && topic.topic_embed&.embed_content_cache.present? if pn == 1 && topic.topic_embed&.embed_content_cache.present?
@@ -50,34 +47,19 @@ module DiscourseAi
end end
summarization_result = strategy.summarize(content, user, &on_partial_blk) summarization_result = strategy.summarize(content, user, &on_partial_blk)
cache_summary(summarization_result)
cache_summary(summarization_result, targets_data.map(&:first), topic)
end end
def summary_targets def summary_targets(topic)
topic.has_summary? ? best_replies : pick_selection topic.has_summary? ? best_replies(topic) : pick_selection(topic)
end end
private private
def summary_sha attr_reader :strategy
@summary_sha ||= build_sha(summary_targets_data.map(&:first))
end
def summary_targets_data def best_replies(topic)
@summary_targets_data ||= summary_targets.pluck(:post_number, :raw, :username)
end
def existing_summary
if !defined?(@existing_summary)
@existing_summary = AiSummary.find_by(target: topic)
if @existing_summary && existing_summary.original_content_sha != summary_sha
@existing_summary.mark_as_outdated
end
end
@existing_summary
end
def best_replies
Post Post
.summary(topic.id) .summary(topic.id)
.where("post_type = ?", Post.types[:regular]) .where("post_type = ?", Post.types[:regular])
@@ -86,7 +68,7 @@ module DiscourseAi
.order(:post_number) .order(:post_number)
end end
def pick_selection def pick_selection(topic)
posts = posts =
Post Post
.where(topic_id: topic.id) .where(topic_id: topic.id)
@@ -105,34 +87,31 @@ module DiscourseAi
.order(:post_number) .order(:post_number)
end end
def delete_cached_summaries! def delete_cached_summaries_of(topic)
AiSummary.where(target: topic).destroy_all AiSummary.where(target: topic).destroy_all
end end
def use_cached?(skip_age_check) # For users without permissions to generate a summary or fresh summaries, we return what we have cached.
can_summarize = Guardian.new(user).can_request_summary? def use_cached?(existing_summary, can_summarize, current_sha, skip_age_check)
existing_summary && existing_summary &&
!( !(
can_summarize && new_targets? && can_summarize && new_targets?(existing_summary, current_sha) &&
(skip_age_check || existing_summary.created_at < 1.hour.ago) (skip_age_check || existing_summary.created_at < 1.hour.ago)
) )
end end
def new_targets? def new_targets?(summary, current_sha)
existing_summary&.original_content_sha != summary_sha summary.original_content_sha != current_sha
end end
def cache_summary(result) def cache_summary(result, post_numbers, topic)
post_numbers = summary_targets_data.map(&:first)
cached_summary = cached_summary =
AiSummary.create!( AiSummary.create!(
target: topic, target: topic,
algorithm: strategy.display_name, algorithm: strategy.display_name,
content_range: (post_numbers.first..post_numbers.last), content_range: (post_numbers.first..post_numbers.last),
summarized_text: result[:summary], summarized_text: result[:summary],
original_content_sha: summary_sha, original_content_sha: build_sha(post_numbers),
) )
cached_summary cached_summary
@@ -13,8 +13,7 @@ export default class AiLlm extends RestModel {
"url", "url",
"api_key", "api_key",
"enabled_chat_bot", "enabled_chat_bot",
"provider_params", "provider_params"
"vision_enabled"
); );
} }
@@ -20,25 +20,22 @@ export default class AiTool extends RestModel {
return this.getProperties(CREATE_ATTRIBUTES); return this.getProperties(CREATE_ATTRIBUTES);
} }
trackParameters(parameters) { workingCopy() {
return new TrackedArray( const attrs = this.getProperties(CREATE_ATTRIBUTES);
parameters?.map((p) => {
attrs.parameters = new TrackedArray(
attrs.parameters?.map((p) => {
const parameter = new TrackedObject(p); const parameter = new TrackedObject(p);
if (parameter.enum && parameter.enum.length) { if (parameter.enum_values) {
parameter.enum = new TrackedArray(parameter.enum); parameter.enumValues = new TrackedArray(parameter.enum_values);
} else { delete parameter.enum_values;
parameter.enum = null;
} }
return parameter; return parameter;
}) })
); );
}
workingCopy() {
const attrs = this.getProperties(CREATE_ATTRIBUTES);
attrs.parameters = this.trackParameters(attrs.parameters);
return this.store.createRecord("ai-tool", attrs); return this.store.createRecord("ai-tool", attrs);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,19 +0,0 @@
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
const AiHelperButtonGroup = <template>
<ul class="ai-helper-button-group" ...attributes>
{{#each @buttons as |button|}}
<li>
<DButton
@icon={{button.icon}}
@label={{button.label}}
@action={{button.action}}
class={{concatClass "btn-flat" button.classes}}
/>
</li>
{{/each}}
</ul>
</template>;
export default AiHelperButtonGroup;
@@ -1,40 +1,38 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { fn } from "@ember/helper"; import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import autoFocus from "discourse/modifiers/auto-focus";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
import not from "truth-helpers/helpers/not"; import not from "truth-helpers/helpers/not";
export default class AiHelperCustomPrompt extends Component { export default class AiHelperCustomPrompt extends Component {
@tracked _customPromptInput;
@action @action
sendInput(event) { setupCustomPrompt() {
if (event.key !== "Enter") { this._customPromptInput = document.querySelector(
return; ".ai-custom-prompt__input"
} );
this._customPromptInput.focus();
}
@action
sendInput() {
return this.args.submit(this.args.promptArgs); return this.args.submit(this.args.promptArgs);
} }
<template> <template>
<div class="ai-custom-prompt"> <div class="ai-custom-prompt" {{didInsert this.setupCustomPrompt}}>
<Input
<input @value={{@value}}
{{on "input" (withEventValue (fn (mut @value)))}}
{{on "keydown" this.sendInput}}
value={{@value}}
placeholder={{i18n placeholder={{i18n
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder" "discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
}} }}
class="ai-custom-prompt__input" class="ai-custom-prompt__input"
type="text" @enter={{this.sendInput}}
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
because built in autofocus doesn't work consistently when component is
invoked twice separetly without a page refresh.
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
--}}
{{autoFocus}}
/> />
<DButton <DButton
@@ -2,7 +2,7 @@ import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
const AiHelperLoading = <template> const AiHelperLoading = <template>
<div class="ai-helper-loading"> <div class="ai-helper-context-menu__loading">
<div class="dot-falling"></div> <div class="dot-falling"></div>
<span> <span>
{{i18n "discourse_ai.ai_helper.context_menu.loading"}} {{i18n "discourse_ai.ai_helper.context_menu.loading"}}
@@ -1,31 +0,0 @@
import { fn } from "@ember/helper";
import DButton from "discourse/components/d-button";
import eq from "truth-helpers/helpers/eq";
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
const AiHelperOptionsList = <template>
<ul class="ai-helper-options">
{{#each @options as |option|}}
{{#if (eq option.name "custom_prompt")}}
<AiHelperCustomPrompt
@value={{@customPromptValue}}
@promptArgs={{option}}
@submit={{@performAction}}
/>
{{else}}
<li data-name={{option.translated_name}} data-value={{option.id}}>
<DButton
@icon={{option.icon}}
@translatedLabel={{option.translated_name}}
@action={{fn @performAction option}}
data-name={{option.name}}
data-value={{option.id}}
class="btn-flat ai-helper-options__button"
/>
</li>
{{/if}}
{{/each}}
</ul>
</template>;
export default AiHelperOptionsList;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,13 +6,13 @@ import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service"; import { service } from "@ember/service";
import AceEditor from "discourse/components/ace-editor";
import BackButton from "discourse/components/back-button"; import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DTooltip from "discourse/components/d-tooltip"; import DTooltip from "discourse/components/d-tooltip";
import withEventValue from "discourse/helpers/with-event-value"; import withEventValue from "discourse/helpers/with-event-value";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import AceEditor from "admin/components/ace-editor";
import ComboBox from "select-kit/components/combo-box"; import ComboBox from "select-kit/components/combo-box";
import AiToolParameterEditor from "./ai-tool-parameter-editor"; import AiToolParameterEditor from "./ai-tool-parameter-editor";
import AiToolTestModal from "./modal/ai-tool-test-modal"; import AiToolTestModal from "./modal/ai-tool-test-modal";
@@ -25,7 +25,6 @@ export default class AiToolEditor extends Component {
@service dialog; @service dialog;
@service modal; @service modal;
@service toasts; @service toasts;
@service store;
@tracked isSaving = false; @tracked isSaving = false;
@tracked editingModel = null; @tracked editingModel = null;
@@ -54,9 +53,8 @@ export default class AiToolEditor extends Component {
@action @action
configurePreset() { configurePreset() {
this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId); this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId);
this.editingModel = this.store this.editingModel = this.args.model.workingCopy();
.createRecord("ai-tool", this.selectedPreset) this.editingModel.setProperties(this.selectedPreset);
.workingCopy();
this.showDelete = false; this.showDelete = false;
} }
@@ -73,6 +71,13 @@ export default class AiToolEditor extends Component {
"summary" "summary"
); );
for (const p of data.parameters) {
if (p.enumValues) {
p.enum_values = p.enumValues;
delete p.enumValues;
}
}
await this.args.model.save(data); await this.args.model.save(data);
this.toasts.success({ this.toasts.success({
@@ -119,7 +124,7 @@ export default class AiToolEditor extends Component {
<template> <template>
<BackButton <BackButton
@route="adminPlugins.show.discourse-ai-tools" @route="adminPlugins.show.discourse-ai-tools"
@label="discourse_ai.tools.back" @label="discourse_ai.ai_tool.back"
/> />
<form <form
@@ -194,7 +199,7 @@ export default class AiToolEditor extends Component {
<label>{{I18n.t "discourse_ai.tools.script"}}</label> <label>{{I18n.t "discourse_ai.tools.script"}}</label>
<AceEditor <AceEditor
@content={{this.editingModel.script}} @content={{this.editingModel.script}}
@onChange={{fn (mut this.editingModel.script)}} @onChange={{withEventValue (fn (mut this.editingModel.script))}}
@mode={{ACE_EDITOR_MODE}} @mode={{ACE_EDITOR_MODE}}
@theme={{ACE_EDITOR_THEME}} @theme={{ACE_EDITOR_THEME}}
@editorId="ai-tool-script-editor" @editorId="ai-tool-script-editor"
@@ -24,7 +24,8 @@ export default class AiToolParameterEditor extends Component {
description: "", description: "",
type: "string", type: "string",
required: false, required: false,
enum: null, enum: false,
enumValues: null,
}) })
); );
} }
@@ -42,30 +43,26 @@ export default class AiToolParameterEditor extends Component {
@action @action
toggleEnum(parameter) { toggleEnum(parameter) {
if (parameter.enum) { parameter.enum = !parameter.enum;
parameter.enum = null; if (!parameter.enum) {
} else { parameter.enumValues = null;
this.addEnumValue(parameter);
} }
} }
@action @action
addEnumValue(parameter) { addEnumValue(parameter) {
parameter.enum ||= new TrackedArray(); parameter.enumValues ||= new TrackedArray();
parameter.enum.push(""); parameter.enumValues.push("");
} }
@action @action
removeEnumValue(parameter, index) { removeEnumValue(parameter, index) {
parameter.enum.splice(index, 1); parameter.enumValues.splice(index, 1);
if (parameter.enum.length === 0) {
parameter.enum = null;
}
} }
@action @action
updateEnumValue(parameter, index, event) { updateEnumValue(parameter, index, event) {
parameter.enum[index] = event.target.value; parameter.enumValues[index] = event.target.value;
} }
<template> <template>
@@ -96,7 +93,6 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (fn this.toggleRequired parameter)}} {{on "input" (fn this.toggleRequired parameter)}}
checked={{parameter.required}} checked={{parameter.required}}
type="checkbox" type="checkbox"
class="parameter-row__required-toggle"
/> />
{{I18n.t "discourse_ai.tools.parameter_required"}} {{I18n.t "discourse_ai.tools.parameter_required"}}
</label> </label>
@@ -106,7 +102,6 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (fn this.toggleEnum parameter)}} {{on "input" (fn this.toggleEnum parameter)}}
checked={{parameter.enum}} checked={{parameter.enum}}
type="checkbox" type="checkbox"
class="parameter-row__enum-toggle"
/> />
{{I18n.t "discourse_ai.tools.parameter_enum"}} {{I18n.t "discourse_ai.tools.parameter_enum"}}
</label> </label>
@@ -120,7 +115,7 @@ export default class AiToolParameterEditor extends Component {
{{#if parameter.enum}} {{#if parameter.enum}}
<div class="parameter-enum-values"> <div class="parameter-enum-values">
{{#each parameter.enum as |enumValue enumIndex|}} {{#each parameter.enumValues as |enumValue enumIndex|}}
<div class="enum-value-row"> <div class="enum-value-row">
<input <input
{{on "change" (fn this.updateEnumValue parameter enumIndex)}} {{on "change" (fn this.updateEnumValue parameter enumIndex)}}
@@ -1,86 +1,21 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal"; import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
export default class ModalDiffModal extends Component { export default class ModalDiffModal extends Component {
@service currentUser;
@tracked loading = false;
@tracked diff;
suggestion = "";
PROOFREAD_ID = -303;
constructor() {
super(...arguments);
this.diff = this.args.model.diff;
next(() => {
if (this.args.model.toolbarEvent) {
this.loadDiff();
}
});
}
async loadDiff() {
this.loading = true;
try {
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
mode: this.PROOFREAD_ID,
text: this.selectedText,
force_default_locale: true,
},
});
this.diff = suggestion.diff;
this.suggestion = suggestion.suggestions[0];
} catch (e) {
popupAjaxError(e);
} finally {
this.loading = false;
}
}
get selectedText() {
const selected = this.args.model.toolbarEvent.selected;
if (selected.value === "") {
return selected.pre + selected.post;
}
return selected.value;
}
@action @action
triggerConfirmChanges() { triggerConfirmChanges() {
this.args.closeModal(); this.args.closeModal();
if (this.args.model.confirm) { this.args.confirm();
this.args.model.confirm();
}
if (this.args.model.toolbarEvent && this.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.selectedText,
this.suggestion
);
}
} }
@action @action
triggerRevertChanges() { triggerRevertChanges() {
this.args.model.revert();
this.args.closeModal(); this.args.closeModal();
this.args.revert();
} }
<template> <template>
@@ -90,46 +25,30 @@ export default class ModalDiffModal extends Component {
@closeModal={{@closeModal}} @closeModal={{@closeModal}}
> >
<:body> <:body>
{{#if this.loading}} {{#if @diff}}
<div class="composer-ai-helper-modal__loading"> {{htmlSafe @diff}}
<CookText @rawText={{this.selectedText}} />
</div>
{{else}} {{else}}
{{#if this.diff}} <div class="composer-ai-helper-modal__old-value">
{{htmlSafe this.diff}} {{@oldValue}}
{{else}} </div>
<div class="composer-ai-helper-modal__old-value">
{{@model.oldValue}}
</div>
<div class="composer-ai-helper-modal__new-value"> <div class="composer-ai-helper-modal__new-value">
{{@model.newValue}} {{@newValue}}
</div> </div>
{{/if}}
{{/if}} {{/if}}
</:body> </:body>
<:footer> <:footer>
{{#if this.loading}} <DButton
<DButton class="btn-primary confirm"
class="btn-primary" @action={{this.triggerConfirmChanges}}
@label="discourse_ai.ai_helper.context_menu.loading" @label="discourse_ai.ai_helper.context_menu.confirm"
@disabled={{true}} />
/> <DButton
{{else}} class="btn-flat"
<DButton @action={{this.triggerRevertChanges}}
class="btn-primary confirm" @label="discourse_ai.ai_helper.context_menu.revert"
@action={{this.triggerConfirmChanges}} />
@label="discourse_ai.ai_helper.context_menu.confirm"
/>
{{#if @model.revert}}
<DButton
class="btn-flat revert"
@action={{this.triggerRevertChanges}}
@label="discourse_ai.ai_helper.context_menu.revert"
/>
{{/if}}
{{/if}}
</:footer> </:footer>
</DModal> </DModal>
</template> </template>
@@ -53,7 +53,7 @@ export default class ThumbnailSuggestions extends Component {
> >
<:body> <:body>
<div class="ai-thumbnail-suggestions"> <div class="ai-thumbnail-suggestions">
{{#each @model.thumbnails as |thumbnail|}} {{#each @thumbnails as |thumbnail|}}
<ThumbnailSuggestionItem <ThumbnailSuggestionItem
@thumbnail={{thumbnail}} @thumbnail={{thumbnail}}
@addSelection={{this.addSelection}} @addSelection={{this.addSelection}}

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