Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8895dad1 | ||
|
|
59d6e2b467 | ||
|
|
c06d7b07d5 | ||
|
|
373e2305d6 | ||
|
|
ad5c48d9ae | ||
|
|
d99c335dab | ||
|
|
01e29ca5d8 | ||
|
|
297c64c3b8 | ||
|
|
ca78b1a1c5 | ||
|
|
39653aed22 | ||
|
|
6781ecd02e | ||
|
|
e264572597 | ||
|
|
70b0db2871 | ||
|
|
2d6ec5e1e6 | ||
|
|
cead887480 | ||
|
|
adc92e1e75 | ||
|
|
0ce17a122f | ||
|
|
cf220c530c | ||
|
|
61ef1932fa | ||
|
|
11e90f5f3f | ||
|
|
b817810d46 | ||
|
|
55dab9c68a | ||
|
|
c29183fc2d | ||
|
|
d72ad84f8f | ||
|
|
e207eba1a4 | ||
|
|
53905f65ac | ||
|
|
ef260be7cf | ||
|
|
0c69bb85ce | ||
|
|
af18603b21 | ||
|
|
7db2589cc4 | ||
|
|
2fb691cba8 | ||
|
|
de0625571b | ||
|
|
296aa24df1 | ||
|
|
3ac2359ff1 | ||
|
|
925949de47 | ||
|
|
b2fbab9fad | ||
|
|
381fa14158 | ||
|
|
77d3a38e49 | ||
|
|
7316058dfc | ||
|
|
9ee82fd8be | ||
|
|
dfea784fc4 | ||
|
|
ff2e18f9ca | ||
|
|
1b3fdad5c7 | ||
|
|
2c6459429f | ||
|
|
c34fcc8a95 |
@@ -50,7 +50,7 @@ module DiscourseAi
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
SiteSetting.ai_spam_detection_enabled = is_enabled
|
SiteSetting.set_and_log("ai_spam_detection_enabled", is_enabled, current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: AiSpamSerializer.new(spam_config, root: false)
|
render json: AiSpamSerializer.new(spam_config, root: false)
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ module DiscourseAi
|
|||||||
<style>
|
<style>
|
||||||
#{artifact.css}
|
#{artifact.css}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
window._discourse_user_data = {
|
||||||
|
#{current_user ? "username: #{current_user.username.to_json}" : "username: null"}
|
||||||
|
};
|
||||||
|
window.discourseArtifactReady = new Promise(resolve => {
|
||||||
|
window._resolveArtifactData = resolve;
|
||||||
|
});
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.data && event.data.type === 'discourse-artifact-data') {
|
||||||
|
window.discourseArtifactData = event.data.dataset || {};
|
||||||
|
Object.assign(window.discourseArtifactData, window._discourse_user_data);
|
||||||
|
window._resolveArtifactData(window.discourseArtifactData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
#{artifact.html}
|
#{artifact.html}
|
||||||
@@ -74,6 +89,19 @@ module DiscourseAi
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
|
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
|
||||||
|
<script>
|
||||||
|
document.querySelector('iframe').addEventListener('load', function() {
|
||||||
|
try {
|
||||||
|
const iframeWindow = this.contentWindow;
|
||||||
|
const message = { type: 'discourse-artifact-data', dataset: {} };
|
||||||
|
|
||||||
|
if (window.frameElement && window.frameElement.dataset) {
|
||||||
|
Object.assign(message.dataset, window.frameElement.dataset);
|
||||||
|
}
|
||||||
|
iframeWindow.postMessage(message, '*');
|
||||||
|
} catch (e) { console.error('Error passing data to artifact:', e); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
HTML
|
HTML
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ module DiscourseAi
|
|||||||
before_action :require_site_settings!
|
before_action :require_site_settings!
|
||||||
|
|
||||||
skip_before_action :preload_json, :check_xhr, only: %i[show asset]
|
skip_before_action :preload_json, :check_xhr, only: %i[show asset]
|
||||||
skip_before_action :verify_authenticity_token, only: ["asset"]
|
skip_before_action :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset]
|
||||||
|
|
||||||
def create
|
def create
|
||||||
ensure_allowed_create!
|
ensure_allowed_create!
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ module DiscourseAi
|
|||||||
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# to stream we must have an appropriate client_id
|
||||||
|
# otherwise we may end up streaming the data to the wrong client
|
||||||
|
raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank?
|
||||||
|
|
||||||
if location == "composer"
|
if location == "composer"
|
||||||
Jobs.enqueue(
|
Jobs.enqueue(
|
||||||
:stream_composer_helper,
|
:stream_composer_helper,
|
||||||
@@ -132,6 +136,7 @@ module DiscourseAi
|
|||||||
prompt: prompt.name,
|
prompt: prompt.name,
|
||||||
custom_prompt: params[:custom_prompt],
|
custom_prompt: params[:custom_prompt],
|
||||||
force_default_locale: params[:force_default_locale] || false,
|
force_default_locale: params[:force_default_locale] || false,
|
||||||
|
client_id: params[:client_id],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
post_id = get_post_param!
|
post_id = get_post_param!
|
||||||
@@ -146,6 +151,7 @@ module DiscourseAi
|
|||||||
text: text,
|
text: text,
|
||||||
prompt: prompt.name,
|
prompt: prompt.name,
|
||||||
custom_prompt: params[:custom_prompt],
|
custom_prompt: params[:custom_prompt],
|
||||||
|
client_id: params[:client_id],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class DetectTranslatePost < ::Jobs::Base
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
return if args[:post_id].blank?
|
||||||
|
|
||||||
|
post = Post.find_by(id: args[:post_id])
|
||||||
|
return if post.blank? || post.raw.blank? || post.deleted_at.present? || post.user_id <= 0
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
topic = post.topic
|
||||||
|
if topic.blank? || topic.category&.read_restricted? ||
|
||||||
|
topic.archetype == Archetype.private_message
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# this job is non-critical
|
||||||
|
# the backfill job will handle failures
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
|
return if locales.blank?
|
||||||
|
|
||||||
|
locales.each do |locale|
|
||||||
|
next if locale == detected_locale
|
||||||
|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to translate post #{post.id} to #{locale}: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MessageBus.publish("/topic/#{post.topic_id}", type: :localized, id: post.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class DetectTranslateTopic < ::Jobs::Base
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
return if args[:topic_id].blank?
|
||||||
|
|
||||||
|
topic = Topic.find_by(id: args[:topic_id])
|
||||||
|
if topic.blank? || topic.title.blank? || topic.deleted_at.present? || topic.user_id <= 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
return if topic.category&.read_restricted?
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# this job is non-critical
|
||||||
|
# the backfill job will handle failures
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
|
return if locales.blank?
|
||||||
|
|
||||||
|
locales.each do |locale|
|
||||||
|
next if locale == detected_locale
|
||||||
|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to translate topic #{topic.id} to #{locale}: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MessageBus.publish("/topic/#{topic.id}", type: :localized, id: 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class LocalizeCategories < ::Jobs::Base
|
||||||
|
cluster_concurrency 1
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
|
||||||
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
|
return if locales.blank?
|
||||||
|
|
||||||
|
cat_id = args[:from_category_id] || Category.order(:id).first&.id
|
||||||
|
last_id = nil
|
||||||
|
|
||||||
|
categories = Category.where("id >= ?", cat_id).order(:id).limit(BATCH_SIZE)
|
||||||
|
return if categories.empty?
|
||||||
|
|
||||||
|
categories.each do |category|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content && category.read_restricted?
|
||||||
|
last_id = category.id
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
CategoryLocalization.transaction do
|
||||||
|
locales.each do |locale|
|
||||||
|
next if CategoryLocalization.exists?(category_id: category.id, locale: locale)
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::CategoryLocalizer.localize(category, locale)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to translate category #{category.id} to #{locale}: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
last_id = category.id
|
||||||
|
end
|
||||||
|
|
||||||
|
if categories.size == BATCH_SIZE
|
||||||
|
Jobs.enqueue_in(10.seconds, :localize_categories, from_category_id: last_id + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class LocalizePosts < ::Jobs::Base
|
||||||
|
cluster_concurrency 1
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
|
||||||
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
|
return if locales.blank?
|
||||||
|
|
||||||
|
limit = args[:limit] || BATCH_SIZE
|
||||||
|
|
||||||
|
locales.each do |locale|
|
||||||
|
posts =
|
||||||
|
Post
|
||||||
|
.joins(
|
||||||
|
"LEFT JOIN post_localizations pl ON pl.post_id = posts.id AND pl.locale = #{ActiveRecord::Base.connection.quote(locale)}",
|
||||||
|
)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.where("posts.user_id > 0")
|
||||||
|
.where.not(raw: [nil, ""])
|
||||||
|
.where.not(locale: nil)
|
||||||
|
.where.not(locale: locale)
|
||||||
|
.where("pl.id IS NULL")
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
posts =
|
||||||
|
posts
|
||||||
|
.joins(:topic)
|
||||||
|
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
|
||||||
|
.where.not(topics: { archetype: Archetype.private_message })
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
||||||
|
posts =
|
||||||
|
posts.where(
|
||||||
|
"posts.created_at > ?",
|
||||||
|
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
posts = posts.order(updated_at: :desc).limit(limit)
|
||||||
|
|
||||||
|
next if posts.empty?
|
||||||
|
|
||||||
|
posts.each do |post|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to translate post #{post.id} to #{locale}: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log("Translated #{posts.size} posts to #{locale}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class LocalizeTopics < ::Jobs::Base
|
||||||
|
cluster_concurrency 1
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
|
||||||
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
|
return if locales.blank?
|
||||||
|
|
||||||
|
limit = args[:limit] || BATCH_SIZE
|
||||||
|
|
||||||
|
locales.each do |locale|
|
||||||
|
topics =
|
||||||
|
Topic
|
||||||
|
.joins(
|
||||||
|
"LEFT JOIN topic_localizations tl ON tl.topic_id = topics.id AND tl.locale = #{ActiveRecord::Base.connection.quote(locale)}",
|
||||||
|
)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.where("topics.user_id > 0")
|
||||||
|
.where.not(locale: nil)
|
||||||
|
.where.not(locale: locale)
|
||||||
|
.where("tl.id IS NULL")
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
topics = topics.where(category_id: Category.where(read_restricted: false).select(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
||||||
|
topics =
|
||||||
|
topics.where(
|
||||||
|
"topics.created_at > ?",
|
||||||
|
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
topics = topics.order(updated_at: :desc).limit(limit)
|
||||||
|
|
||||||
|
next if topics.empty?
|
||||||
|
|
||||||
|
topics.each do |topic|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to translate topic #{topic.id} to #{locale}: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log("Translated #{topics.size} topics to #{locale}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,6 +8,7 @@ module Jobs
|
|||||||
return unless args[:prompt]
|
return unless args[:prompt]
|
||||||
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[:text]
|
||||||
|
return unless args[:client_id]
|
||||||
|
|
||||||
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
|
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ module Jobs
|
|||||||
user,
|
user,
|
||||||
"/discourse-ai/ai-helper/stream_composer_suggestion",
|
"/discourse-ai/ai-helper/stream_composer_suggestion",
|
||||||
force_default_locale: args[:force_default_locale],
|
force_default_locale: args[:force_default_locale],
|
||||||
|
client_id: args[:client_id],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class CategoryLocalizationBackfill < ::Jobs::Scheduled
|
||||||
|
every 12.hours
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
return if SiteSetting.experimental_content_localization_supported_locales.blank?
|
||||||
|
|
||||||
|
Jobs.enqueue(:localize_categories)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class PostLocalizationBackfill < ::Jobs::Scheduled
|
||||||
|
every 5.minutes
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
|
||||||
|
return if SiteSetting.experimental_content_localization_supported_locales.blank?
|
||||||
|
return if SiteSetting.ai_translation_backfill_rate == 0
|
||||||
|
|
||||||
|
Jobs.enqueue(:localize_posts, limit: SiteSetting.ai_translation_backfill_rate)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class PostsLocaleDetectionBackfill < ::Jobs::Scheduled
|
||||||
|
every 5.minutes
|
||||||
|
sidekiq_options retry: false
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
return if SiteSetting.ai_translation_backfill_rate == 0
|
||||||
|
|
||||||
|
posts =
|
||||||
|
Post
|
||||||
|
.where(locale: nil)
|
||||||
|
.where(deleted_at: nil)
|
||||||
|
.where("posts.user_id > 0")
|
||||||
|
.where.not(raw: [nil, ""])
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
public_categories = Category.where(read_restricted: false).pluck(:id)
|
||||||
|
posts =
|
||||||
|
posts
|
||||||
|
.joins(:topic)
|
||||||
|
.where(topics: { category_id: public_categories })
|
||||||
|
.where(topics: { archetype: "regular" })
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
||||||
|
posts =
|
||||||
|
posts.where(
|
||||||
|
"posts.created_at > ?",
|
||||||
|
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
posts = posts.order(updated_at: :desc).limit(SiteSetting.ai_translation_backfill_rate)
|
||||||
|
return if posts.empty?
|
||||||
|
|
||||||
|
posts.each do |post|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to detect post #{post.id}'s locale: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log("Detected #{posts.size} post locales")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class TopicLocalizationBackfill < ::Jobs::Scheduled
|
||||||
|
every 5.minutes
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
|
||||||
|
return if SiteSetting.experimental_content_localization_supported_locales.blank?
|
||||||
|
return if SiteSetting.ai_translation_backfill_rate == 0
|
||||||
|
|
||||||
|
Jobs.enqueue(:localize_topics, limit: SiteSetting.ai_translation_backfill_rate)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class TopicsLocaleDetectionBackfill < ::Jobs::Scheduled
|
||||||
|
every 5.minutes
|
||||||
|
sidekiq_options retry: false
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
limit = SiteSetting.ai_translation_backfill_rate
|
||||||
|
return if limit == 0
|
||||||
|
|
||||||
|
topics = Topic.where(locale: nil, deleted_at: nil).where("topics.user_id > 0")
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
topics = topics.where(category_id: Category.where(read_restricted: false).select(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
||||||
|
topics =
|
||||||
|
topics.where(
|
||||||
|
"topics.created_at > ?",
|
||||||
|
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
topics = topics.order(updated_at: :desc).limit(limit)
|
||||||
|
return if topics.empty?
|
||||||
|
|
||||||
|
topics.each do |topic|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
# do nothing, there are too many sporadic lookup failures
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to detect topic #{topic.id}'s locale: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log("Detected #{topics.size} topic locales")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -266,6 +266,7 @@ class AiPersona < ActiveRecord::Base
|
|||||||
define_method(:top_p) { @ai_persona&.top_p }
|
define_method(:top_p) { @ai_persona&.top_p }
|
||||||
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
|
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
|
||||||
define_method(:uploads) { @ai_persona&.uploads }
|
define_method(:uploads) { @ai_persona&.uploads }
|
||||||
|
define_method(:response_format) { @ai_persona&.response_format }
|
||||||
define_method(:examples) { @ai_persona&.examples }
|
define_method(:examples) { @ai_persona&.examples }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import htmlClass from "discourse/helpers/html-class";
|
import htmlClass from "discourse/helpers/html-class";
|
||||||
@@ -88,6 +89,15 @@ export default class AiArtifactComponent extends Component {
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setDataAttributes(element) {
|
||||||
|
if (this.args.dataAttributes) {
|
||||||
|
Object.entries(this.args.dataAttributes).forEach(([key, value]) => {
|
||||||
|
element.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.expanded}}
|
{{#if this.expanded}}
|
||||||
{{htmlClass "ai-artifact-expanded"}}
|
{{htmlClass "ai-artifact-expanded"}}
|
||||||
@@ -118,6 +128,7 @@ export default class AiArtifactComponent extends Component {
|
|||||||
src={{this.artifactUrl}}
|
src={{this.artifactUrl}}
|
||||||
width="100%"
|
width="100%"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
|
{{didInsert this.setDataAttributes}}
|
||||||
></iframe>
|
></iframe>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#unless this.requireClickToRun}}
|
{{#unless this.requireClickToRun}}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { composeAiBotMessage } from "../lib/ai-bot-helper";
|
|||||||
import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager";
|
import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager";
|
||||||
|
|
||||||
export default class AiBotHeaderIcon extends Component {
|
export default class AiBotHeaderIcon extends Component {
|
||||||
|
@service appEvents;
|
||||||
@service composer;
|
@service composer;
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
@service navigationMenu;
|
@service navigationMenu;
|
||||||
@@ -51,6 +52,7 @@ export default class AiBotHeaderIcon extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.siteSettings.ai_bot_enable_dedicated_ux) {
|
if (this.siteSettings.ai_bot_enable_dedicated_ux) {
|
||||||
|
this.appEvents.trigger("discourse-ai:bot-header-icon-clicked");
|
||||||
return this.router.transitionTo("discourse-ai-bot-conversations");
|
return this.router.transitionTo("discourse-ai-bot-conversations");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DButton from "discourse/components/d-button";
|
|||||||
import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager";
|
import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager";
|
||||||
|
|
||||||
export default class AiBotSidebarNewConversation extends Component {
|
export default class AiBotSidebarNewConversation extends Component {
|
||||||
|
@service appEvents;
|
||||||
@service router;
|
@service router;
|
||||||
@service sidebarState;
|
@service sidebarState;
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ export default class AiBotSidebarNewConversation extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
routeTo() {
|
routeTo() {
|
||||||
|
this.appEvents.trigger("discourse-ai:new-conversation-btn-clicked");
|
||||||
|
|
||||||
if (this.router.currentRouteName !== "discourse-ai-bot-conversations") {
|
if (this.router.currentRouteName !== "discourse-ai-bot-conversations") {
|
||||||
this.router.transitionTo("/discourse-ai/ai-bot/conversations");
|
this.router.transitionTo("/discourse-ai/ai-bot/conversations");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default class AiPersonaCollapsableExample extends Component {
|
|||||||
(if (eq pairIdx 0) "user" "model")
|
(if (eq pairIdx 0) "user" "model")
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@validation="required|length:1,100"
|
@validation="required|length:1,5000"
|
||||||
@disabled={{@system}}
|
@disabled={{@system}}
|
||||||
as |field|
|
as |field|
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export default class AiPostHelperMenu extends Component {
|
|||||||
text: this.args.data.selectedText,
|
text: this.args.data.selectedText,
|
||||||
post_id: this.args.data.quoteState.postId,
|
post_id: this.args.data.quoteState.postId,
|
||||||
custom_prompt: this.customPromptValue,
|
custom_prompt: this.customPromptValue,
|
||||||
|
client_id: this.messageBus.clientId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,21 @@ import concatClass from "discourse/helpers/concat-class";
|
|||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import { bind } from "discourse/lib/decorators";
|
import { bind } from "discourse/lib/decorators";
|
||||||
|
import { escapeExpression } from "discourse/lib/utilities";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
import DiffStreamer from "../../lib/diff-streamer";
|
import DiffStreamer from "../../lib/diff-streamer";
|
||||||
import SmoothStreamer from "../../lib/smooth-streamer";
|
import SmoothStreamer from "../../lib/smooth-streamer";
|
||||||
import AiIndicatorWave from "../ai-indicator-wave";
|
import AiIndicatorWave from "../ai-indicator-wave";
|
||||||
|
|
||||||
|
const CHANNEL = "/discourse-ai/ai-helper/stream_composer_suggestion";
|
||||||
|
|
||||||
export default class ModalDiffModal extends Component {
|
export default class ModalDiffModal extends Component {
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
@service messageBus;
|
@service messageBus;
|
||||||
|
|
||||||
@tracked loading = false;
|
@tracked loading = false;
|
||||||
|
@tracked finalResult = "";
|
||||||
|
@tracked escapedSelectedText = escapeExpression(this.args.model.selectedText);
|
||||||
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
|
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
|
||||||
@tracked suggestion = "";
|
@tracked suggestion = "";
|
||||||
@tracked
|
@tracked
|
||||||
@@ -35,8 +40,20 @@ export default class ModalDiffModal extends Component {
|
|||||||
this.suggestChanges();
|
this.suggestChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get diffResult() {
|
||||||
|
if (this.diffStreamer.diff?.length > 0) {
|
||||||
|
return this.diffStreamer.diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevents flash by showing the
|
||||||
|
// original text when the diff is empty
|
||||||
|
return this.escapedSelectedText;
|
||||||
|
}
|
||||||
|
|
||||||
get isStreaming() {
|
get isStreaming() {
|
||||||
return this.diffStreamer.isStreaming || this.smoothStreamer.isStreaming;
|
// diffStreamer stops Streaming when it is finished with a chunk, looking at isDone is safe
|
||||||
|
// it starts off not done
|
||||||
|
return !this.diffStreamer.isDone || this.smoothStreamer.isStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
get primaryBtnLabel() {
|
get primaryBtnLabel() {
|
||||||
@@ -51,20 +68,26 @@ export default class ModalDiffModal extends Component {
|
|||||||
|
|
||||||
@bind
|
@bind
|
||||||
subscribe() {
|
subscribe() {
|
||||||
const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
|
this.messageBus.subscribe(CHANNEL, this.updateResult);
|
||||||
this.messageBus.subscribe(channel, this.updateResult);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
unsubscribe() {
|
cleanup() {
|
||||||
const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
|
// stop all callbacks so it does not end up streaming pointlessly
|
||||||
this.messageBus.subscribe(channel, this.updateResult);
|
this.smoothStreamer.resetStreaming();
|
||||||
|
this.diffStreamer.reset();
|
||||||
|
this.messageBus.unsubscribe(CHANNEL, this.updateResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async updateResult(result) {
|
updateResult(result) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
|
if (result.done) {
|
||||||
|
this.finalResult = result.result;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.args.model.showResultAsDiff) {
|
if (this.args.model.showResultAsDiff) {
|
||||||
this.diffStreamer.updateResult(result, "result");
|
this.diffStreamer.updateResult(result, "result");
|
||||||
} else {
|
} else {
|
||||||
@@ -87,6 +110,7 @@ export default class ModalDiffModal extends Component {
|
|||||||
text: this.args.model.selectedText,
|
text: this.args.model.selectedText,
|
||||||
custom_prompt: this.args.model.customPromptValue,
|
custom_prompt: this.args.model.customPromptValue,
|
||||||
force_default_locale: true,
|
force_default_locale: true,
|
||||||
|
client_id: this.messageBus.clientId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -105,10 +129,15 @@ export default class ModalDiffModal extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.args.model.showResultAsDiff && this.diffStreamer.suggestion) {
|
const finalResult =
|
||||||
|
this.finalResult?.length > 0
|
||||||
|
? this.finalResult
|
||||||
|
: this.diffStreamer.suggestion;
|
||||||
|
|
||||||
|
if (this.args.model.showResultAsDiff && finalResult) {
|
||||||
this.args.model.toolbarEvent.replaceText(
|
this.args.model.toolbarEvent.replaceText(
|
||||||
this.args.model.selectedText,
|
this.args.model.selectedText,
|
||||||
this.diffStreamer.suggestion
|
finalResult
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,10 +149,14 @@ export default class ModalDiffModal extends Component {
|
|||||||
@closeModal={{@closeModal}}
|
@closeModal={{@closeModal}}
|
||||||
>
|
>
|
||||||
<:body>
|
<:body>
|
||||||
<div {{didInsert this.subscribe}} {{willDestroy this.unsubscribe}}>
|
<div
|
||||||
|
{{didInsert this.subscribe}}
|
||||||
|
{{willDestroy this.cleanup}}
|
||||||
|
class="text-preview"
|
||||||
|
>
|
||||||
{{#if this.loading}}
|
{{#if this.loading}}
|
||||||
<div class="composer-ai-helper-modal__loading">
|
<div class="composer-ai-helper-modal__loading">
|
||||||
<CookText @rawText={{@model.selectedText}} />
|
{{~@model.selectedText~}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div
|
<div
|
||||||
@@ -132,10 +165,11 @@ export default class ModalDiffModal extends Component {
|
|||||||
"streamable-content"
|
"streamable-content"
|
||||||
(if this.isStreaming "streaming")
|
(if this.isStreaming "streaming")
|
||||||
(if @model.showResultAsDiff "inline-diff")
|
(if @model.showResultAsDiff "inline-diff")
|
||||||
|
(if this.diffStreamer.isThinking "thinking")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{{#if @model.showResultAsDiff}}
|
{{~#if @model.showResultAsDiff~}}
|
||||||
{{htmlSafe this.diffStreamer.diff}}
|
<span class="diff-inner">{{htmlSafe this.diffResult}}</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if this.smoothStreamer.isStreaming}}
|
{{#if this.smoothStreamer.isStreaming}}
|
||||||
<CookText
|
<CookText
|
||||||
|
|||||||
+9
-3
@@ -19,7 +19,6 @@ export default class AiPostHelperTrigger extends Component {
|
|||||||
@tracked menuState = this.MENU_STATES.triggers;
|
@tracked menuState = this.MENU_STATES.triggers;
|
||||||
@tracked showMainButtons = true;
|
@tracked showMainButtons = true;
|
||||||
@tracked showAiButtons = true;
|
@tracked showAiButtons = true;
|
||||||
@tracked originalPostHTML = null;
|
|
||||||
@tracked postHighlighted = false;
|
@tracked postHighlighted = false;
|
||||||
@tracked currentMenu = this.menu.getByIdentifier(
|
@tracked currentMenu = this.menu.getByIdentifier(
|
||||||
"post-text-selection-toolbar"
|
"post-text-selection-toolbar"
|
||||||
@@ -45,7 +44,6 @@ export default class AiPostHelperTrigger extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.originalPostHTML = postElement.innerHTML;
|
|
||||||
this.selectedText = this.args.outletArgs.data.quoteState.buffer;
|
this.selectedText = this.args.outletArgs.data.quoteState.buffer;
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -121,7 +119,15 @@ export default class AiPostHelperTrigger extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
postElement.innerHTML = this.originalPostHTML;
|
const highlightedSpans = postElement.querySelectorAll(
|
||||||
|
"span.ai-helper-highlighted-selection"
|
||||||
|
);
|
||||||
|
|
||||||
|
highlightedSpans.forEach((span) => {
|
||||||
|
const textNode = document.createTextNode(span.textContent);
|
||||||
|
span.parentNode.replaceChild(textNode, span);
|
||||||
|
});
|
||||||
|
|
||||||
this.postHighlighted = false;
|
this.postHighlighted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-10
@@ -7,6 +7,10 @@ import AiSummaryModal from "../../components/modal/ai-summary-modal";
|
|||||||
export default class AiSummaryTrigger extends Component {
|
export default class AiSummaryTrigger extends Component {
|
||||||
@service modal;
|
@service modal;
|
||||||
|
|
||||||
|
get isAiConversation() {
|
||||||
|
return this.args.outletArgs.topic.is_bot_pm;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
openAiSummaryModal() {
|
openAiSummaryModal() {
|
||||||
this.modal.show(AiSummaryModal, {
|
this.modal.show(AiSummaryModal, {
|
||||||
@@ -18,15 +22,17 @@ export default class AiSummaryTrigger extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if @outletArgs.topic.summarizable}}
|
{{#unless this.isAiConversation}}
|
||||||
<section class="topic-map__additional-contents toggle-summary">
|
{{#if @outletArgs.topic.summarizable}}
|
||||||
<DButton
|
<section class="topic-map__additional-contents toggle-summary">
|
||||||
@label="summary.buttons.generate"
|
<DButton
|
||||||
@icon="discourse-sparkles"
|
@label="summary.buttons.generate"
|
||||||
@action={{this.openAiSummaryModal}}
|
@icon="discourse-sparkles"
|
||||||
class="btn-default ai-summarization-button"
|
@action={{this.openAiSummaryModal}}
|
||||||
/>
|
class="btn-default ai-summarization-button"
|
||||||
</section>
|
/>
|
||||||
{{/if}}
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
{{/unless}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { or } from "truth-helpers";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import Participant from "discourse/components/header/topic/participant";
|
||||||
|
import AddPmParticipants from "discourse/components/modal/add-pm-participants";
|
||||||
|
|
||||||
|
export default class AiConversationInvite extends Component {
|
||||||
|
static shouldRender(args) {
|
||||||
|
return args.topic.is_bot_pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@service site;
|
||||||
|
@service modal;
|
||||||
|
@service header;
|
||||||
|
@service sidebarState;
|
||||||
|
|
||||||
|
get participants() {
|
||||||
|
const participants = [
|
||||||
|
...(this.header.topicInfo.details?.allowed_users ?? []),
|
||||||
|
...(this.header.topicInfo.details?.allowed_groups ?? []),
|
||||||
|
];
|
||||||
|
return participants;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
showInvite() {
|
||||||
|
this.modal.show(AddPmParticipants, {
|
||||||
|
model: {
|
||||||
|
title: "discourse_ai.ai_bot.invite_ai_conversation.title",
|
||||||
|
inviteModel: this.args.outletArgs.topic,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-conversation__participants">
|
||||||
|
<DButton
|
||||||
|
@icon="user-plus"
|
||||||
|
@label="discourse_ai.ai_bot.invite_ai_conversation.button"
|
||||||
|
@action={{this.showInvite}}
|
||||||
|
class="btn-default ai-conversations__invite-button"
|
||||||
|
/>
|
||||||
|
{{#each this.participants as |participant|}}
|
||||||
|
<Participant
|
||||||
|
@user={{participant}}
|
||||||
|
@type={{if participant.username "user" "group"}}
|
||||||
|
@username={{or participant.username participant.name}}
|
||||||
|
@avatarSize="medium"
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { tracked } from "@glimmer/tracking";
|
|
||||||
import Controller from "@ember/controller";
|
|
||||||
import { action } from "@ember/object";
|
|
||||||
import { getOwner } from "@ember/owner";
|
|
||||||
import { service } from "@ember/service";
|
|
||||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
||||||
import UppyUpload from "discourse/lib/uppy/uppy-upload";
|
|
||||||
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
|
|
||||||
import { clipboardHelpers } from "discourse/lib/utilities";
|
|
||||||
|
|
||||||
export default class DiscourseAiBotConversations extends Controller {
|
|
||||||
@service aiBotConversationsHiddenSubmit;
|
|
||||||
@service currentUser;
|
|
||||||
@service mediaOptimizationWorker;
|
|
||||||
@service site;
|
|
||||||
@service siteSettings;
|
|
||||||
|
|
||||||
@tracked uploads = new TrackedArray();
|
|
||||||
// Don't track this directly - we'll get it from uppyUpload
|
|
||||||
|
|
||||||
textarea = null;
|
|
||||||
uppyUpload = null;
|
|
||||||
fileInputEl = null;
|
|
||||||
|
|
||||||
_handlePaste = (event) => {
|
|
||||||
if (document.activeElement !== this.textarea) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { canUpload, canPasteHtml, types } = clipboardHelpers(event, {
|
|
||||||
siteSettings: this.siteSettings,
|
|
||||||
canUpload: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event && event.clipboardData && event.clipboardData.files) {
|
|
||||||
this.uppyUpload.addFiles([...event.clipboardData.files], {
|
|
||||||
pasted: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(...arguments);
|
|
||||||
|
|
||||||
this.uppyUpload = new UppyUpload(getOwner(this), {
|
|
||||||
id: "ai-bot-file-uploader",
|
|
||||||
type: "ai-bot-conversation",
|
|
||||||
useMultipartUploadsIfAvailable: true,
|
|
||||||
|
|
||||||
uppyReady: () => {
|
|
||||||
if (this.siteSettings.composer_media_optimization_image_enabled) {
|
|
||||||
this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
|
|
||||||
optimizeFn: (data, opts) =>
|
|
||||||
this.mediaOptimizationWorker.optimizeImage(data, opts),
|
|
||||||
runParallel: !this.site.isMobileDevice,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
|
|
||||||
const inProgressUpload = this.inProgressUploads?.find(
|
|
||||||
(upl) => upl.id === file.id
|
|
||||||
);
|
|
||||||
if (inProgressUpload && !inProgressUpload.processing) {
|
|
||||||
inProgressUpload.processing = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
|
|
||||||
const inProgressUpload = this.inProgressUploads?.find(
|
|
||||||
(upl) => upl.id === file.id
|
|
||||||
);
|
|
||||||
if (inProgressUpload) {
|
|
||||||
inProgressUpload.processing = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup paste listener for the textarea
|
|
||||||
this.textarea?.addEventListener("paste", this._handlePaste);
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadDone: (upload) => {
|
|
||||||
this.uploads.push(upload);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fix: Don't try to set inProgressUploads directly
|
|
||||||
onProgressUploadsChanged: () => {
|
|
||||||
// This is just for UI triggers - we're already tracking inProgressUploads
|
|
||||||
this.notifyPropertyChange("inProgressUploads");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
willDestroy() {
|
|
||||||
super.willDestroy(...arguments);
|
|
||||||
this.textarea?.removeEventListener("paste", this._handlePaste);
|
|
||||||
this.uppyUpload?.teardown();
|
|
||||||
}
|
|
||||||
|
|
||||||
get loading() {
|
|
||||||
return this.aiBotConversationsHiddenSubmit?.loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
get inProgressUploads() {
|
|
||||||
return this.uppyUpload?.inProgressUploads || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
get showUploadsContainer() {
|
|
||||||
return this.uploads?.length > 0 || this.inProgressUploads?.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
setPersonaId(id) {
|
|
||||||
this.aiBotConversationsHiddenSubmit.personaId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
setTargetRecipient(username) {
|
|
||||||
this.aiBotConversationsHiddenSubmit.targetUsername = username;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateInputValue(value) {
|
|
||||||
this._autoExpandTextarea();
|
|
||||||
this.aiBotConversationsHiddenSubmit.inputValue =
|
|
||||||
value.target?.value || value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
handleKeyDown(event) {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
this.prepareAndSubmitToBot();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
setTextArea(element) {
|
|
||||||
this.textarea = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
registerFileInput(element) {
|
|
||||||
if (element) {
|
|
||||||
this.fileInputEl = element;
|
|
||||||
if (this.uppyUpload) {
|
|
||||||
this.uppyUpload.setup(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
openFileUpload() {
|
|
||||||
if (this.fileInputEl) {
|
|
||||||
this.fileInputEl.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
removeUpload(upload) {
|
|
||||||
this.uploads = new TrackedArray(this.uploads.filter((u) => u !== upload));
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
cancelUpload(upload) {
|
|
||||||
this.uppyUpload.cancelSingleUpload({
|
|
||||||
fileId: upload.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
async prepareAndSubmitToBot() {
|
|
||||||
// Pass uploads to the service before submitting
|
|
||||||
this.aiBotConversationsHiddenSubmit.uploads = this.uploads;
|
|
||||||
try {
|
|
||||||
await this.aiBotConversationsHiddenSubmit.submitToBot();
|
|
||||||
this.uploads = new TrackedArray();
|
|
||||||
} catch (error) {
|
|
||||||
popupAjaxError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_autoExpandTextarea() {
|
|
||||||
this.textarea.style.height = "auto";
|
|
||||||
this.textarea.style.height = this.textarea.scrollHeight + "px";
|
|
||||||
|
|
||||||
// Get the max-height value from CSS (30vh)
|
|
||||||
const maxHeight = parseInt(getComputedStyle(this.textarea).maxHeight, 10);
|
|
||||||
|
|
||||||
// Only enable scrolling if content exceeds max-height
|
|
||||||
if (this.textarea.scrollHeight > maxHeight) {
|
|
||||||
this.textarea.style.overflowY = "auto";
|
|
||||||
} else {
|
|
||||||
this.textarea.style.overflowY = "hidden";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { later } from "@ember/runloop";
|
|||||||
import PostUpdater from "./updaters/post-updater";
|
import PostUpdater from "./updaters/post-updater";
|
||||||
|
|
||||||
const PROGRESS_INTERVAL = 40;
|
const PROGRESS_INTERVAL = 40;
|
||||||
const GIVE_UP_INTERVAL = 60000;
|
const GIVE_UP_INTERVAL = 600000; // 10 minutes which is our max thinking time for now
|
||||||
export const MIN_LETTERS_PER_INTERVAL = 6;
|
export const MIN_LETTERS_PER_INTERVAL = 6;
|
||||||
const MAX_FLUSH_TIME = 800;
|
const MAX_FLUSH_TIME = 800;
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ export default class PostUpdater extends StreamUpdater {
|
|||||||
|
|
||||||
constructor(postStream, postId) {
|
constructor(postStream, postId) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.postStream = postStream;
|
this.postStream = postStream;
|
||||||
this.postId = postId;
|
this.postId = postId;
|
||||||
this.post = postStream.findLoadedPost(postId);
|
this.post = postStream.findLoadedPost(postId);
|
||||||
|
const topicId = postStream.topic.id;
|
||||||
|
|
||||||
if (this.post) {
|
if (this.post) {
|
||||||
this.postElement = document.querySelector(
|
this.postElement = document.querySelector(
|
||||||
`#post_${this.post.post_number}`
|
`.topic-area[data-topic-id="${topicId}"] #post_${this.post.post_number}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,10 @@ export default class PostUpdater extends StreamUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setCooked(value) {
|
async setCooked(value) {
|
||||||
|
if (!this.postElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.post.set("cooked", value);
|
this.post.set("cooked", value);
|
||||||
|
|
||||||
(await loadMorphlex()).morphInner(
|
(await loadMorphlex()).morphInner(
|
||||||
|
|||||||
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