diff --git a/app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb b/app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb new file mode 100644 index 00000000..c197f9fc --- /dev/null +++ b/app/controllers/discourse_ai/ai_bot/shared_ai_conversations_controller.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + class SharedAiConversationsController < ::ApplicationController + requires_plugin ::DiscourseAi::PLUGIN_NAME + requires_login only: %i[create update destroy] + before_action :require_site_settings! + + skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required, only: %i[show] + + def create + ensure_allowed_create! + + RateLimiter.new(current_user, "share-ai-conversation", 10, 1.minute).performed! + + shared_conversation = SharedAiConversation.share_conversation(current_user, @topic) + + if shared_conversation.persisted? + render json: success_json.merge(share_key: shared_conversation.share_key) + else + render json: failed_json.merge(error: I18n.t("discourse_ai.share_ai.failed_to_share")), + status: :unprocessable_entity + end + end + + def destroy + ensure_allowed_destroy! + @shared_conversation.destroy + render json: + success_json.merge(message: I18n.t("discourse_ai.share_ai.conversation_deleted")) + end + + def show + @shared_conversation = SharedAiConversation.find_by(share_key: params[:share_key]) + raise Discourse::NotFound if @shared_conversation.blank? + + expires_in 1.minute, public: true + response.headers["X-Robots-Tag"] = "noindex" + + if request.format.json? + render json: success_json.merge(@shared_conversation.to_json) + else + render "show", layout: false + end + end + + def preview + ensure_allowed_preview! + data = SharedAiConversation.build_conversation_data(@topic, include_usernames: true) + data[:error] = @error if @error + data[:share_key] = @shared_conversation.share_key if @shared_conversation + data[:topic_id] = @topic.id + render json: data + end + + private + + def require_site_settings! + if !SiteSetting.discourse_ai_enabled || + !SiteSetting.ai_bot_public_sharing_allowed_groups_map.any? || + !SiteSetting.ai_bot_enabled + raise Discourse::NotFound + end + end + + def ensure_allowed_preview! + @topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound if !@topic + + @shared_conversation = SharedAiConversation.find_by(target: @topic) + + @error = DiscourseAi::AiBot::EntryPoint.ai_share_error(@topic, guardian) + if @error == :not_allowed + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "discourse_ai.share_ai.errors.#{@error}", + ) + end + end + + def ensure_allowed_destroy! + @shared_conversation = SharedAiConversation.find_by(share_key: params[:share_key]) + + raise Discourse::InvalidAccess if @shared_conversation.blank? + + guardian.ensure_can_destroy_shared_ai_bot_conversation!(@shared_conversation) + end + + def ensure_allowed_create! + @topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound if !@topic + + error = DiscourseAi::AiBot::EntryPoint.ai_share_error(@topic, guardian) + if error + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "discourse_ai.share_ai.errors.#{error}", + ) + end + end + end + end +end diff --git a/app/jobs/regular/stream_post_helper.rb b/app/jobs/regular/stream_post_helper.rb index 9b103d06..5f3955ee 100644 --- a/app/jobs/regular/stream_post_helper.rb +++ b/app/jobs/regular/stream_post_helper.rb @@ -12,8 +12,7 @@ module Jobs topic = post.topic reply_to = post.reply_to_post - guardian = Guardian.new(user) - return unless guardian.can_see?(post) + return unless user.guardian.can_see?(post) prompt = CompletionPrompt.enabled_by_name("explain") diff --git a/app/models/shared_ai_conversation.rb b/app/models/shared_ai_conversation.rb new file mode 100644 index 00000000..970deaa9 --- /dev/null +++ b/app/models/shared_ai_conversation.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +class SharedAiConversation < ActiveRecord::Base + DEFAULT_MAX_POSTS = 100 + + belongs_to :user + belongs_to :target, polymorphic: true + + validates :user_id, presence: true + validates :target, presence: true + validates :context, presence: true + validates :share_key, presence: true, uniqueness: true + + before_validation :generate_share_key, on: :create + + def self.share_conversation(user, target, max_posts: DEFAULT_MAX_POSTS) + raise "Target must be a topic for now" if !target.is_a?(Topic) + + conversation = find_by(user: user, target: target) + conversation_data = build_conversation_data(target, max_posts: max_posts) + + if conversation + conversation.update(**conversation_data) + conversation + else + create(user_id: user.id, target: target, **conversation_data) + end + end + + # Technically this may end up being a chat message + # but this name works + class SharedPost + attr_accessor :user + attr_reader :id, :user_id, :created_at, :cooked, :persona + def initialize(post) + @id = post[:id] + @user_id = post[:user_id] + @created_at = DateTime.parse(post[:created_at]) + @cooked = post[:cooked] + @persona = post[:persona] + end + end + + def populated_context + return @populated_context if @populated_context + @populated_context = context.map { |post| SharedPost.new(post.symbolize_keys) } + populate_user_info!(@populated_context) + @populated_context + end + + def to_json + posts = + self.populated_context.map do |post| + { + id: post.id, + cooked: post.cooked, + username: post.user.username, + created_at: post.created_at, + } + end + { llm_name: self.llm_name, share_key: self.share_key, title: self.title, posts: posts } + end + + def url + "#{Discourse.base_uri}/discourse-ai/ai-bot/shared-ai-conversations/#{share_key}" + end + + def html_excerpt + html = +"" + populated_context.each do |post| + text = + PrettyText.excerpt( + post.cooked, + 400, + text_entities: true, + strip_links: true, + strip_details: true, + ) + + html << "
#{post.user.username}: #{text}
" + if html.length > 1000 + html << "...
" + break + end + end + html << "#{I18n.t("discourse_ai.share_ai.read_more")}" + html + end + + def onebox + <<~HTML +${post.username}:
`); + context.push(post.cooked); + }); + return htmlSafe(context.join("\n")); + } + + async generateShareURL() { + try { + const response = await ajax( + "/discourse-ai/ai-bot/shared-ai-conversations", + { + type: "POST", + data: { + topic_id: this.args.model.topic_id, + }, + } + ); + + const url = getAbsoluteURL( + `/discourse-ai/ai-bot/shared-ai-conversations/${response.share_key}` + ); + this.shareKey = response.share_key; + + return new Blob([url], { type: "text/plain" }); + } catch (e) { + popupAjaxError(e); + return; + } + } + + get primaryLabel() { + return this.shareKey + ? "discourse_ai.ai_bot.share_full_topic_modal.update" + : "discourse_ai.ai_bot.share_full_topic_modal.share"; + } + + @action + async deleteLink() { + try { + await ajax( + `/discourse-ai/ai-bot/shared-ai-conversations/${this.shareKey}.json`, + { + type: "DELETE", + } + ); + + this.shareKey = null; + } catch (e) { + popupAjaxError(e); + } + } + + @action + async share() { + await clipboardCopyAsync(this.generateShareURL.bind(this)); + this.toasts.success({ + duration: 3000, + data: { + message: I18n.t("discourse_ai.ai_bot.conversation_shared"), + }, + }); + } + + +