# frozen_string_literal: true module DiscourseAi module AiBot class Playground BYPASS_AI_REPLY_CUSTOM_FIELD = "discourse_ai_bypass_ai_reply" BOT_USER_PREF_ID_CUSTOM_FIELD = "discourse_ai_bot_user_pref_id" attr_reader :bot # An abstraction to manage the bot and topic interactions. # The bot will take care of completions while this class updates the topic title # and stream replies. def self.find_chat_persona(message, channel, user) if channel.direct_message_channel? AiPersona .allowed_modalities(allow_chat_direct_messages: true) .find do |p| p[:user_id].in?(channel.allowed_user_ids) && (user.group_ids & p[:allowed_group_ids]) end else # let's defer on the parse if there is no @ in the message if message.message.include?("@") mentions = message.parsed_mentions.parsed_direct_mentions if mentions.present? AiPersona .allowed_modalities(allow_chat_channel_mentions: true) .find { |p| p[:username].in?(mentions) && (user.group_ids & p[:allowed_group_ids]) } end end end end def self.schedule_chat_reply(message, channel, user, context) return if !SiteSetting.ai_bot_enabled all_chat = AiPersona.allowed_modalities( allow_chat_channel_mentions: true, allow_chat_direct_messages: true, ) return if all_chat.blank? return if all_chat.any? { |m| m[:user_id] == user.id } persona = find_chat_persona(message, channel, user) return if !persona post_ids = nil post_ids = context.dig(:context, :post_ids) if context.is_a?(Hash) ::Jobs.enqueue( :create_ai_chat_reply, channel_id: channel.id, message_id: message.id, persona_id: persona[:id], context_post_ids: post_ids, ) end def self.is_bot_user_id?(user_id) # this will catch everything and avoid any feedback loops # we could get feedback loops between say discobot and ai-bot or third party plugins # and bots user_id.to_i <= 0 end def self.get_bot_user(post:, all_llm_users:, mentionables:) bot_user = nil if post.topic.private_message? # this ensures that we reply using the correct llm # 1. if we have a preferred llm user we use that # 2. if we don't just take first topic allowed user # 3. if we don't have that we take the first mentionable bot_user = nil if preferred_user = all_llm_users.find { |id, username| id == post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD].to_i } bot_user = User.find_by(id: preferred_user[0]) end bot_user ||= post.topic.topic_allowed_users.where(user_id: all_llm_users.map(&:first)).first&.user bot_user ||= post .topic .topic_allowed_users .where(user_id: mentionables.map { |m| m[:user_id] }) .first &.user end bot_user end def self.schedule_reply(post) return if is_bot_user_id?(post.user_id) mentionables = nil if post.topic.private_message? mentionables = AiPersona.allowed_modalities(user: post.user, allow_personal_messages: true) else mentionables = AiPersona.allowed_modalities(user: post.user, allow_topic_mentions: true) end mentioned = nil all_llm_users = LlmModel .where(enabled_chat_bot: true) .joins(:user) .pluck("users.id", "users.username_lower") bot_user = get_bot_user(post: post, all_llm_users: all_llm_users, mentionables: mentionables) mentions = nil if mentionables.present? || (bot_user && post.topic.private_message?) mentions = post.mentions.map(&:downcase) # in case we are replying to a post by a bot if post.reply_to_post_number && post.reply_to_post&.user mentions << post.reply_to_post.user.username_lower end end if mentionables.present? mentioned = mentionables.find { |mentionable| mentions.include?(mentionable[:username]) } # direct PM to mentionable if !mentioned && bot_user mentioned = mentionables.find { |mentionable| bot_user.id == mentionable[:user_id] } end # public topic so we need to use the persona user bot_user ||= User.find_by(id: mentioned[:user_id]) if mentioned end if !mentioned && bot_user && post.reply_to_post_number && !post.reply_to_post.user&.bot? # replying to a non-bot user return end if bot_user topic_persona_id = post.topic.custom_fields["ai_persona_id"] topic_persona_id = topic_persona_id.to_i if topic_persona_id.present? persona_id = mentioned&.dig(:id) || topic_persona_id persona = nil if persona_id persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) end if !persona && persona_name = post.topic.custom_fields["ai_persona"] persona = DiscourseAi::Personas::Persona.find_by(user: post.user, name: persona_name) end # edge case, llm was mentioned in an ai persona conversation if persona_id == topic_persona_id && post.topic.private_message? && persona && all_llm_users.present? if !persona.force_default_llm && mentions.present? mentioned_llm_user_id, _ = all_llm_users.find { |id, username| mentions.include?(username) } if mentioned_llm_user_id bot_user = User.find_by(id: mentioned_llm_user_id) || bot_user end end end persona ||= DiscourseAi::Personas::General bot_user = User.find(persona.user_id) if persona && persona.force_default_llm bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new) new(bot).update_playground_with(post) end end def self.reply_to_post( post:, user: nil, persona_id: nil, whisper: nil, add_user_to_pm: false, stream_reply: false, auto_set_title: false, silent_mode: false ) ai_persona = AiPersona.find_by(id: persona_id) raise Discourse::InvalidParameters.new(:persona_id) if !ai_persona persona_class = ai_persona.class_instance persona = persona_class.new bot_user = user || ai_persona.user raise Discourse::InvalidParameters.new(:user) if bot_user.nil? bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona) playground = new(bot) playground.reply_to( post, whisper: whisper, context_style: :topic, add_user_to_pm: add_user_to_pm, stream_reply: stream_reply, auto_set_title: auto_set_title, silent_mode: silent_mode, ) rescue => e if Rails.env.test? p e puts e.backtrace[0..10] else raise e end end def initialize(bot) @bot = bot end def update_playground_with(post) schedule_bot_reply(post) if can_attach?(post) end def title_playground(post, user) messages = DiscourseAi::Completions::PromptMessagesBuilder.messages_from_post( post, max_posts: 5, bot_usernames: available_bot_usernames, include_uploads: bot.persona.class.vision_enabled, ) # conversation context may contain tool calls, and confusing user names # clean it up conversation = +"" messages.each do |context| if context[:type] == :user conversation << "User said:\n#{context[:content]}\n\n" elsif context[:type] == :model conversation << "Model said:\n#{context[:content]}\n\n" end end system_insts = <<~TEXT.strip You are titlebot. Given a conversation, you will suggest a title. - You will never respond with anything but the suggested title. - You will always match the conversation language in your title suggestion. - Title will capture the essence of the conversation. TEXT instruction = <<~TEXT.strip Given the following conversation: {{{ #{conversation} }}} Reply only with a title that is 7 words or less. TEXT title_prompt = DiscourseAi::Completions::Prompt.new( system_insts, messages: [type: :user, content: instruction], topic_id: post.topic_id, ) new_title = bot .llm .generate(title_prompt, user: user, feature_name: "bot_title") .strip .split("\n") .last PostRevisor.new(post.topic.first_post, post.topic).revise!( bot.bot_user, title: new_title.sub(/\A"/, "").sub(/"\Z/, ""), ) allowed_users = post.topic.topic_allowed_users.pluck(:user_id) MessageBus.publish( "/discourse-ai/ai-bot/topic/#{post.topic.id}", { title: post.topic.title }, user_ids: allowed_users, ) end def reply_to_chat_message(message, channel, context_post_ids) persona_user = User.find(bot.persona.class.user_id) participants = channel.user_chat_channel_memberships.map { |m| m.user.username } context_post_ids = nil if !channel.direct_message_channel? max_chat_messages = 40 if bot.persona.class.respond_to?(:max_context_posts) max_chat_messages = bot.persona.class.max_context_posts || 40 end if !channel.direct_message_channel? # we are interacting via mentions ... strip mention instruction_message = message.message.gsub(/@#{bot.bot_user.username}/i, "").strip end context = DiscourseAi::Personas::BotContext.new( participants: participants, message_id: message.id, channel_id: channel.id, context_post_ids: context_post_ids, messages: DiscourseAi::Completions::PromptMessagesBuilder.messages_from_chat( message, channel: channel, context_post_ids: context_post_ids, include_uploads: bot.persona.class.vision_enabled, max_messages: max_chat_messages, bot_user_ids: available_bot_user_ids, instruction_message: instruction_message, ), user: message.user, skip_tool_details: true, ) reply = nil guardian = Guardian.new(persona_user) force_thread = message.thread_id.nil? && channel.direct_message_channel? in_reply_to_id = channel.direct_message_channel? ? message.id : nil new_prompts = bot.reply(context) do |partial, cancel, placeholder| if !reply # just eat all leading spaces we can not create the message next if partial.blank? reply = ChatSDK::Message.create( raw: partial, thread_id: message.thread_id, channel_id: channel.id, guardian: guardian, in_reply_to_id: in_reply_to_id, force_thread: force_thread, enforce_membership: !channel.direct_message_channel?, ) ChatSDK::Message.start_stream(message_id: reply.id, guardian: guardian) else streaming = ChatSDK::Message.stream(message_id: reply.id, raw: partial, guardian: guardian) if !streaming cancel&.call break end end end if new_prompts.length > 1 && reply.id ChatMessageCustomPrompt.create!(message_id: reply.id, custom_prompt: new_prompts) end ChatSDK::Message.stop_stream(message_id: reply.id, guardian: guardian) if reply reply end def reply_to( post, custom_instructions: nil, whisper: nil, context_style: nil, add_user_to_pm: true, stream_reply: nil, auto_set_title: true, silent_mode: false, &blk ) # this is a multithreading issue # post custom prompt is needed and it may not # be properly loaded, ensure it is loaded PostCustomPrompt.none if silent_mode auto_set_title = false stream_reply = false end reply = +"" post_streamer = nil post_type = ( if (whisper || post.post_type == Post.types[:whisper]) Post.types[:whisper] else Post.types[:regular] end ) # safeguard max_context_posts = 40 if bot.persona.class.respond_to?(:max_context_posts) max_context_posts = bot.persona.class.max_context_posts || 40 end context = DiscourseAi::Personas::BotContext.new( post: post, custom_instructions: custom_instructions, messages: DiscourseAi::Completions::PromptMessagesBuilder.messages_from_post( post, style: context_style, max_posts: max_context_posts, include_uploads: bot.persona.class.vision_enabled, bot_usernames: available_bot_usernames, ), ) reply_user = bot.bot_user if bot.persona.class.respond_to?(:user_id) reply_user = User.find_by(id: bot.persona.class.user_id) || reply_user end stream_reply = post.topic.private_message? if stream_reply.nil? # we need to ensure persona user is allowed to reply to the pm if post.topic.private_message? && add_user_to_pm if !post.topic.topic_allowed_users.exists?(user_id: reply_user.id) post.topic.topic_allowed_users.create!(user_id: reply_user.id) end # edge case, maybe the llm user is missing? if !post.topic.topic_allowed_users.exists?(user_id: bot.bot_user.id) post.topic.topic_allowed_users.create!(user_id: bot.bot_user.id) end # we store the id of the last bot_user, this is then used to give it preference if post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD].to_i != bot.bot_user.id post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD] = bot.bot_user.id post.topic.save_custom_fields end end if stream_reply reply_post = PostCreator.create!( reply_user, topic_id: post.topic_id, raw: "", skip_validations: true, skip_jobs: true, post_type: post_type, skip_guardian: true, ) publish_update(reply_post, { raw: reply_post.cooked }) redis_stream_key = "gpt_cancel:#{reply_post.id}" Discourse.redis.setex(redis_stream_key, 60, 1) end context.skip_tool_details ||= !bot.persona.class.tool_details post_streamer = PostStreamer.new(delay: Rails.env.test? ? 0 : 0.5) if stream_reply started_thinking = false new_custom_prompts = bot.reply(context) do |partial, cancel, placeholder, type| if type == :thinking && !started_thinking reply << "
#{I18n.t("discourse_ai.ai_bot.thinking")}" started_thinking = true end if type != :thinking && started_thinking reply << "
\n\n" started_thinking = false end reply << partial raw = reply.dup raw << "\n\n" << placeholder if placeholder.present? if blk && type != :tool_details && type != :partial_tool && type != :partial_invoke blk.call(partial) end if stream_reply && !Discourse.redis.get(redis_stream_key) cancel&.call reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) # we do not break out, cause if we do # we will not get results from bot # leading to broken context # we need to trust it to cancel at the endpoint end if post_streamer post_streamer.run_later do Discourse.redis.expire(redis_stream_key, 60) publish_update(reply_post, { raw: raw }) end end end return if reply.blank? || silent_mode if stream_reply post_streamer.finish post_streamer = nil # land the final message prior to saving so we don't clash reply_post.cooked = PrettyText.cook(reply) publish_final_update(reply_post) reply_post.revise( bot.bot_user, { raw: reply }, skip_validations: true, skip_revision: true, ) else reply_post = PostCreator.create!( reply_user, topic_id: post.topic_id, raw: reply, skip_validations: true, post_type: post_type, skip_guardian: true, ) end # a bit messy internally, but this is how we tell is_thinking = new_custom_prompts.any? { |prompt| prompt[4].present? } if is_thinking || new_custom_prompts.length > 1 reply_post.post_custom_prompt ||= reply_post.build_post_custom_prompt(custom_prompt: []) prompt = reply_post.post_custom_prompt.custom_prompt || [] prompt.concat(new_custom_prompts) reply_post.post_custom_prompt.update!(custom_prompt: prompt) end reply_post rescue => e if reply_post details = e.message.to_s reply = "#{reply}\n\n#{I18n.t("discourse_ai.ai_bot.reply_error", details: details)}" reply_post.revise( bot.bot_user, { raw: reply }, skip_validations: true, skip_revision: true, ) end raise e ensure # since we are skipping validations and jobs we # may need to fix participant count if reply_post && reply_post.topic && reply_post.topic.private_message? && reply_post.topic.participant_count < 2 reply_post.topic.update!(participant_count: 2) end post_streamer&.finish(skip_callback: true) publish_final_update(reply_post) if stream_reply if reply_post && post.post_number == 1 && post.topic.private_message? && auto_set_title title_playground(reply_post, post.user) end end def available_bot_usernames @bot_usernames ||= AiPersona.joins(:user).pluck(:username).concat(available_bot_users.map(&:username)) end def available_bot_user_ids @bot_ids ||= AiPersona.joins(:user).pluck("users.id").concat(available_bot_users.map(&:id)) end private def available_bot_users @available_bots ||= User.joins("INNER JOIN llm_models llm ON llm.user_id = users.id").where(active: true) end def publish_final_update(reply_post) return if @published_final_update if reply_post publish_update(reply_post, { cooked: reply_post.cooked, done: true }) # we subscribe at position -2 so we will always get this message # moving all cooked on every page load is wasteful ... this means # we have a benign message at the end, 2 is set to ensure last message # is delivered publish_update(reply_post, { noop: true }) @published_final_update = true end end def can_attach?(post) return false if bot.bot_user.nil? return false if post.topic.private_message? && post.post_type != Post.types[:regular] return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank? return false if post.custom_fields[BYPASS_AI_REPLY_CUSTOM_FIELD].present? true end def schedule_bot_reply(post) persona_id = DiscourseAi::Personas::Persona.system_personas[bot.persona.class] || bot.persona.class.id ::Jobs.enqueue( :create_ai_reply, post_id: post.id, bot_user_id: bot.bot_user.id, persona_id: persona_id, ) end def context(topic) { site_url: Discourse.base_url, site_title: SiteSetting.title, site_description: SiteSetting.site_description, time: Time.zone.now, participants: topic.allowed_users.map(&:username).join(", "), } end def publish_update(bot_reply_post, payload) payload = { post_id: bot_reply_post.id, post_number: bot_reply_post.post_number }.merge( payload, ) MessageBus.publish( "discourse-ai/ai-bot/topic/#{bot_reply_post.topic_id}", payload, user_ids: bot_reply_post.topic.allowed_user_ids, max_backlog_size: 2, max_backlog_age: 60, ) end end end end