Compare commits

..
Author SHA1 Message Date
Penar Musaraj 059da39a4a DEV: output raw response in dev mode
Makes it easier to debug issues, for example if rate limits are hit.
2025-05-13 16:54:10 -04:00
214 changed files with 925 additions and 7276 deletions
@@ -50,7 +50,7 @@ module DiscourseAi
) )
end end
SiteSetting.set_and_log("ai_spam_detection_enabled", is_enabled, current_user) SiteSetting.ai_spam_detection_enabled = is_enabled
end end
render json: AiSpamSerializer.new(spam_config, root: false) render json: AiSpamSerializer.new(spam_config, root: false)
@@ -42,21 +42,6 @@ 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}
@@ -89,19 +74,6 @@ 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 :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset] skip_before_action :verify_authenticity_token, only: ["asset"]
def create def create
ensure_allowed_create! ensure_allowed_create!
@@ -124,10 +124,6 @@ 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,
@@ -136,7 +132,6 @@ 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!
@@ -151,7 +146,6 @@ 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
-51
View File
@@ -1,51 +0,0 @@
# 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
@@ -1,49 +0,0 @@
# 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
-51
View File
@@ -1,51 +0,0 @@
# 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
-68
View File
@@ -1,68 +0,0 @@
# 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
-63
View File
@@ -1,63 +0,0 @@
# 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,7 +8,6 @@ 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])
@@ -22,7 +21,6 @@ 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
@@ -1,16 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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
@@ -1,56 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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
@@ -1,47 +0,0 @@
# 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
-1
View File
@@ -266,7 +266,6 @@ 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,7 +1,6 @@
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";
@@ -89,15 +88,6 @@ 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"}}
@@ -128,7 +118,6 @@ 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,7 +10,6 @@ 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;
@@ -52,7 +51,6 @@ 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,7 +5,6 @@ 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;
@@ -15,8 +14,6 @@ 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,5000" @validation="required|length:1,100"
@disabled={{@system}} @disabled={{@system}}
as |field| as |field|
> >
@@ -242,7 +242,6 @@ 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,21 +12,16 @@ 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
@@ -40,20 +35,8 @@ 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() {
// diffStreamer stops Streaming when it is finished with a chunk, looking at isDone is safe return this.diffStreamer.isStreaming || this.smoothStreamer.isStreaming;
// it starts off not done
return !this.diffStreamer.isDone || this.smoothStreamer.isStreaming;
} }
get primaryBtnLabel() { get primaryBtnLabel() {
@@ -68,26 +51,20 @@ export default class ModalDiffModal extends Component {
@bind @bind
subscribe() { subscribe() {
this.messageBus.subscribe(CHANNEL, this.updateResult); const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
this.messageBus.subscribe(channel, this.updateResult);
} }
@bind @bind
cleanup() { unsubscribe() {
// stop all callbacks so it does not end up streaming pointlessly const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
this.smoothStreamer.resetStreaming(); this.messageBus.subscribe(channel, this.updateResult);
this.diffStreamer.reset();
this.messageBus.unsubscribe(CHANNEL, this.updateResult);
} }
@action @action
updateResult(result) { async 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 {
@@ -110,7 +87,6 @@ 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) {
@@ -129,15 +105,10 @@ export default class ModalDiffModal extends Component {
); );
} }
const finalResult = if (this.args.model.showResultAsDiff && this.diffStreamer.suggestion) {
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,
finalResult this.diffStreamer.suggestion
); );
} }
} }
@@ -149,14 +120,10 @@ export default class ModalDiffModal extends Component {
@closeModal={{@closeModal}} @closeModal={{@closeModal}}
> >
<:body> <:body>
<div <div {{didInsert this.subscribe}} {{willDestroy this.unsubscribe}}>
{{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">
{{~@model.selectedText~}} <CookText @rawText={{@model.selectedText}} />
</div> </div>
{{else}} {{else}}
<div <div
@@ -165,11 +132,10 @@ 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}}
<span class="diff-inner">{{htmlSafe this.diffResult}}</span> {{htmlSafe this.diffStreamer.diff}}
{{else}} {{else}}
{{#if this.smoothStreamer.isStreaming}} {{#if this.smoothStreamer.isStreaming}}
<CookText <CookText
@@ -19,6 +19,7 @@ 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"
@@ -44,6 +45,7 @@ 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();
@@ -119,15 +121,7 @@ export default class AiPostHelperTrigger extends Component {
return; return;
} }
const highlightedSpans = postElement.querySelectorAll( postElement.innerHTML = this.originalPostHTML;
"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;
} }
@@ -7,10 +7,6 @@ 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, {
@@ -22,7 +18,6 @@ export default class AiSummaryTrigger extends Component {
} }
<template> <template>
{{#unless this.isAiConversation}}
{{#if @outletArgs.topic.summarizable}} {{#if @outletArgs.topic.summarizable}}
<section class="topic-map__additional-contents toggle-summary"> <section class="topic-map__additional-contents toggle-summary">
<DButton <DButton
@@ -33,6 +28,5 @@ export default class AiSummaryTrigger extends Component {
/> />
</section> </section>
{{/if}} {{/if}}
{{/unless}}
</template> </template>
} }
@@ -1,55 +0,0 @@
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>
}
@@ -0,0 +1,200 @@
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 = 600000; // 10 minutes which is our max thinking time for now const GIVE_UP_INTERVAL = 60000;
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,15 +12,13 @@ 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(
`.topic-area[data-topic-id="${topicId}"] #post_${this.post.post_number}` `#post_${this.post.post_number}`
); );
} }
} }
@@ -59,10 +57,6 @@ 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