2024-01-04 10:44:07 -03:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module DiscourseAi
|
|
|
|
module AiBot
|
|
|
|
class Playground
|
2024-10-30 20:24:39 +11:00
|
|
|
BYPASS_AI_REPLY_CUSTOM_FIELD = "discourse_ai_bypass_ai_reply"
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
attr_reader :bot
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
# 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.
|
|
|
|
|
2024-05-07 10:30:39 +10:00
|
|
|
def self.find_chat_persona(message, channel, user)
|
2024-05-06 09:49:02 +10:00
|
|
|
if channel.direct_message_channel?
|
2024-10-16 07:20:31 +11:00
|
|
|
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
|
2024-05-07 10:30:39 +10:00
|
|
|
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?
|
2024-10-16 07:20:31 +11:00
|
|
|
AiPersona
|
|
|
|
.allowed_modalities(allow_chat_channel_mentions: true)
|
|
|
|
.find { |p| p[:username].in?(mentions) && (user.group_ids & p[:allowed_group_ids]) }
|
2024-05-06 09:49:02 +10:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-07 10:30:39 +10:00
|
|
|
def self.schedule_chat_reply(message, channel, user, context)
|
|
|
|
return if !SiteSetting.ai_bot_enabled
|
2024-10-16 07:20:31 +11:00
|
|
|
|
|
|
|
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 }
|
2024-05-07 10:30:39 +10:00
|
|
|
|
|
|
|
persona = find_chat_persona(message, channel, user)
|
|
|
|
return if !persona
|
|
|
|
|
2024-05-21 17:17:02 +10:00
|
|
|
post_ids = nil
|
|
|
|
post_ids = context.dig(:context, :post_ids) if context.is_a?(Hash)
|
|
|
|
|
2024-05-07 10:30:39 +10:00
|
|
|
::Jobs.enqueue(
|
|
|
|
:create_ai_chat_reply,
|
|
|
|
channel_id: channel.id,
|
|
|
|
message_id: message.id,
|
|
|
|
persona_id: persona[:id],
|
2024-05-21 17:17:02 +10:00
|
|
|
context_post_ids: post_ids,
|
2024-05-07 10:30:39 +10:00
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2024-02-28 16:46:32 +11:00
|
|
|
def self.is_bot_user_id?(user_id)
|
2024-03-05 10:02:49 +11:00
|
|
|
# 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
|
2024-02-28 16:46:32 +11:00
|
|
|
end
|
2024-02-15 16:37:59 +11:00
|
|
|
|
2024-02-28 16:46:32 +11:00
|
|
|
def self.schedule_reply(post)
|
|
|
|
return if is_bot_user_id?(post.user_id)
|
2024-10-16 07:20:31 +11:00
|
|
|
mentionables = nil
|
2024-02-28 16:46:32 +11:00
|
|
|
|
2024-10-16 07:20:31 +11:00
|
|
|
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
|
2024-02-15 16:37:59 +11:00
|
|
|
|
|
|
|
bot_user = nil
|
|
|
|
mentioned = nil
|
|
|
|
|
2024-11-26 07:19:56 +11:00
|
|
|
all_llm_users =
|
|
|
|
LlmModel
|
|
|
|
.where(enabled_chat_bot: true)
|
|
|
|
.joins(:user)
|
|
|
|
.pluck("users.id", "users.username_lower")
|
2024-10-16 07:20:31 +11:00
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
if post.topic.private_message?
|
2024-10-16 07:20:31 +11:00
|
|
|
# this is an edge case, you started a PM with a different bot
|
2024-11-26 07:19:56 +11:00
|
|
|
bot_user =
|
|
|
|
post.topic.topic_allowed_users.where(user_id: all_llm_users.map(&:first)).first&.user
|
2024-02-28 16:46:32 +11:00
|
|
|
bot_user ||=
|
|
|
|
post
|
|
|
|
.topic
|
|
|
|
.topic_allowed_users
|
|
|
|
.where(user_id: mentionables.map { |m| m[:user_id] })
|
|
|
|
.first
|
|
|
|
&.user
|
2024-02-15 16:37:59 +11:00
|
|
|
end
|
|
|
|
|
2024-11-26 07:19:56 +11:00
|
|
|
mentions = nil
|
|
|
|
if mentionables.present? || (bot_user && post.topic.private_message?)
|
2024-02-15 16:37:59 +11:00
|
|
|
mentions = post.mentions.map(&:downcase)
|
2024-03-19 20:15:12 +11:00
|
|
|
|
|
|
|
# in case we are replying to a post by a bot
|
2024-08-07 14:22:32 -03:00
|
|
|
if post.reply_to_post_number && post.reply_to_post&.user
|
2024-03-19 20:15:12 +11:00
|
|
|
mentions << post.reply_to_post.user.username_lower
|
|
|
|
end
|
2024-11-26 07:19:56 +11:00
|
|
|
end
|
2024-03-19 20:15:12 +11:00
|
|
|
|
2024-11-26 07:19:56 +11:00
|
|
|
if mentionables.present?
|
2024-02-28 16:46:32 +11:00
|
|
|
mentioned = mentionables.find { |mentionable| mentions.include?(mentionable[:username]) }
|
2024-02-15 16:37:59 +11:00
|
|
|
|
2024-02-28 16:46:32 +11:00
|
|
|
# direct PM to mentionable
|
|
|
|
if !mentioned && bot_user
|
|
|
|
mentioned = mentionables.find { |mentionable| bot_user.id == mentionable[:user_id] }
|
2024-02-15 16:37:59 +11:00
|
|
|
end
|
2024-02-28 16:46:32 +11:00
|
|
|
|
|
|
|
# public topic so we need to use the persona user
|
|
|
|
bot_user ||= User.find_by(id: mentioned[:user_id]) if mentioned
|
2024-02-15 16:37:59 +11:00
|
|
|
end
|
|
|
|
|
2025-03-04 12:22:30 +11:00
|
|
|
if !mentioned && bot_user && post.reply_to_post_number && !post.reply_to_post.user&.bot?
|
2024-03-19 20:15:12 +11:00
|
|
|
# replying to a non-bot user
|
|
|
|
return
|
|
|
|
end
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
if bot_user
|
2024-11-26 07:19:56 +11:00
|
|
|
topic_persona_id = post.topic.custom_fields["ai_persona_id"]
|
|
|
|
persona_id = mentioned&.dig(:id) || topic_persona_id
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
persona = nil
|
|
|
|
|
|
|
|
if persona_id
|
|
|
|
persona =
|
|
|
|
DiscourseAi::AiBot::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::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name)
|
|
|
|
end
|
|
|
|
|
2024-11-26 07:19:56 +11:00
|
|
|
# edge case, llm was mentioned in an ai persona conversation
|
|
|
|
if persona_id == topic_persona_id.to_i && 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
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
persona ||= DiscourseAi::AiBot::Personas::General
|
|
|
|
|
2024-10-16 07:20:31 +11:00
|
|
|
bot_user = User.find(persona.user_id) if persona && persona.force_default_llm
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
|
|
|
|
new(bot).update_playground_with(post)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2025-03-06 17:18:15 +11:00
|
|
|
def self.reply_to_post(post:, user: nil, persona_id: nil, whisper: nil)
|
|
|
|
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::AiBot::Bot.as(bot_user, persona: persona)
|
|
|
|
playground = DiscourseAi::AiBot::Playground.new(bot)
|
|
|
|
|
|
|
|
playground.reply_to(post, whisper: whisper, context_style: :topic)
|
|
|
|
end
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
def initialize(bot)
|
|
|
|
@bot = bot
|
|
|
|
end
|
|
|
|
|
|
|
|
def update_playground_with(post)
|
2024-09-03 15:52:20 +10:00
|
|
|
schedule_bot_reply(post) if can_attach?(post)
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
|
2025-03-06 09:41:09 +11:00
|
|
|
def conversation_context(post, style: nil)
|
2024-01-04 10:44:07 -03:00
|
|
|
# Pay attention to the `post_number <= ?` here.
|
|
|
|
# We want to inject the last post as context because they are translated differently.
|
2024-02-15 16:37:59 +11:00
|
|
|
|
|
|
|
# also setting default to 40, allowing huge contexts costs lots of tokens
|
|
|
|
max_posts = 40
|
|
|
|
if bot.persona.class.respond_to?(:max_context_posts)
|
|
|
|
max_posts = bot.persona.class.max_context_posts || 40
|
|
|
|
end
|
|
|
|
|
|
|
|
post_types = [Post.types[:regular]]
|
|
|
|
post_types << Post.types[:whisper] if post.post_type == Post.types[:whisper]
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
context =
|
|
|
|
post
|
|
|
|
.topic
|
|
|
|
.posts
|
FEATURE: Add vision support to AI personas (Claude 3) (#546)
This commit adds the ability to enable vision for AI personas, allowing them to understand images that are posted in the conversation.
For personas with vision enabled, any images the user has posted will be resized to be within the configured max_pixels limit, base64 encoded and included in the prompt sent to the AI provider.
The persona editor allows enabling/disabling vision and has a dropdown to select the max supported image size (low, medium, high). Vision is disabled by default.
This initial vision support has been tested and implemented with Anthropic's claude-3 models which accept images in a special format as part of the prompt.
Other integrations will need to be updated to support images.
Several specs were added to test the new functionality at the persona, prompt building and API layers.
- Gemini is omitted, pending API support for Gemini 1.5. Current Gemini bot is not performing well, adding images is unlikely to make it perform any better.
- Open AI is omitted, vision support on GPT-4 it limited in that the API has no tool support when images are enabled so we would need to full back to a different prompting technique, something that would add lots of complexity
---------
Co-authored-by: Martin Brennan <martin@discourse.org>
2024-03-27 14:30:11 +11:00
|
|
|
.joins(:user)
|
2024-01-04 10:44:07 -03:00
|
|
|
.joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id")
|
|
|
|
.where("post_number <= ?", post.post_number)
|
|
|
|
.order("post_number desc")
|
2024-02-15 16:37:59 +11:00
|
|
|
.where("post_type in (?)", post_types)
|
|
|
|
.limit(max_posts)
|
FEATURE: Add vision support to AI personas (Claude 3) (#546)
This commit adds the ability to enable vision for AI personas, allowing them to understand images that are posted in the conversation.
For personas with vision enabled, any images the user has posted will be resized to be within the configured max_pixels limit, base64 encoded and included in the prompt sent to the AI provider.
The persona editor allows enabling/disabling vision and has a dropdown to select the max supported image size (low, medium, high). Vision is disabled by default.
This initial vision support has been tested and implemented with Anthropic's claude-3 models which accept images in a special format as part of the prompt.
Other integrations will need to be updated to support images.
Several specs were added to test the new functionality at the persona, prompt building and API layers.
- Gemini is omitted, pending API support for Gemini 1.5. Current Gemini bot is not performing well, adding images is unlikely to make it perform any better.
- Open AI is omitted, vision support on GPT-4 it limited in that the API has no tool support when images are enabled so we would need to full back to a different prompting technique, something that would add lots of complexity
---------
Co-authored-by: Martin Brennan <martin@discourse.org>
2024-03-27 14:30:11 +11:00
|
|
|
.pluck(
|
|
|
|
"posts.raw",
|
|
|
|
"users.username",
|
|
|
|
"post_custom_prompts.custom_prompt",
|
|
|
|
"(
|
|
|
|
SELECT array_agg(ref.upload_id)
|
|
|
|
FROM upload_references ref
|
|
|
|
WHERE ref.target_type = 'Post' AND ref.target_id = posts.id
|
|
|
|
) as upload_ids",
|
|
|
|
)
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
builder = DiscourseAi::Completions::PromptMessagesBuilder.new
|
2025-03-06 09:41:09 +11:00
|
|
|
builder.topic = post.topic
|
2024-01-04 10:44:07 -03:00
|
|
|
|
FEATURE: Add vision support to AI personas (Claude 3) (#546)
This commit adds the ability to enable vision for AI personas, allowing them to understand images that are posted in the conversation.
For personas with vision enabled, any images the user has posted will be resized to be within the configured max_pixels limit, base64 encoded and included in the prompt sent to the AI provider.
The persona editor allows enabling/disabling vision and has a dropdown to select the max supported image size (low, medium, high). Vision is disabled by default.
This initial vision support has been tested and implemented with Anthropic's claude-3 models which accept images in a special format as part of the prompt.
Other integrations will need to be updated to support images.
Several specs were added to test the new functionality at the persona, prompt building and API layers.
- Gemini is omitted, pending API support for Gemini 1.5. Current Gemini bot is not performing well, adding images is unlikely to make it perform any better.
- Open AI is omitted, vision support on GPT-4 it limited in that the API has no tool support when images are enabled so we would need to full back to a different prompting technique, something that would add lots of complexity
---------
Co-authored-by: Martin Brennan <martin@discourse.org>
2024-03-27 14:30:11 +11:00
|
|
|
context.reverse_each do |raw, username, custom_prompt, upload_ids|
|
2024-01-04 10:44:07 -03:00
|
|
|
custom_prompt_translation =
|
|
|
|
Proc.new do |message|
|
|
|
|
# We can't keep backwards-compatibility for stored functions.
|
|
|
|
# Tool syntax requires a tool_call_id which we don't have.
|
|
|
|
if message[2] != "function"
|
|
|
|
custom_context = {
|
|
|
|
content: message[0],
|
2024-01-12 14:36:44 -03:00
|
|
|
type: message[2].present? ? message[2].to_sym : :model,
|
2024-01-04 10:44:07 -03:00
|
|
|
}
|
|
|
|
|
2024-01-12 14:36:44 -03:00
|
|
|
custom_context[:id] = message[1] if custom_context[:type] != :model
|
2024-03-09 08:46:40 +11:00
|
|
|
custom_context[:name] = message[3] if message[3]
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2025-03-04 12:22:30 +11:00
|
|
|
thinking = message[4]
|
|
|
|
custom_context[:thinking] = thinking if thinking
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
builder.push(**custom_context)
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
if custom_prompt.present?
|
2024-03-06 06:04:37 +11:00
|
|
|
custom_prompt.each(&custom_prompt_translation)
|
2024-01-04 10:44:07 -03:00
|
|
|
else
|
|
|
|
context = {
|
|
|
|
content: raw,
|
2024-01-12 14:36:44 -03:00
|
|
|
type: (available_bot_usernames.include?(username) ? :model : :user),
|
2024-01-04 10:44:07 -03:00
|
|
|
}
|
|
|
|
|
2024-01-12 14:36:44 -03:00
|
|
|
context[:id] = username if context[:type] == :user
|
2024-01-04 10:44:07 -03:00
|
|
|
|
FEATURE: Add vision support to AI personas (Claude 3) (#546)
This commit adds the ability to enable vision for AI personas, allowing them to understand images that are posted in the conversation.
For personas with vision enabled, any images the user has posted will be resized to be within the configured max_pixels limit, base64 encoded and included in the prompt sent to the AI provider.
The persona editor allows enabling/disabling vision and has a dropdown to select the max supported image size (low, medium, high). Vision is disabled by default.
This initial vision support has been tested and implemented with Anthropic's claude-3 models which accept images in a special format as part of the prompt.
Other integrations will need to be updated to support images.
Several specs were added to test the new functionality at the persona, prompt building and API layers.
- Gemini is omitted, pending API support for Gemini 1.5. Current Gemini bot is not performing well, adding images is unlikely to make it perform any better.
- Open AI is omitted, vision support on GPT-4 it limited in that the API has no tool support when images are enabled so we would need to full back to a different prompting technique, something that would add lots of complexity
---------
Co-authored-by: Martin Brennan <martin@discourse.org>
2024-03-27 14:30:11 +11:00
|
|
|
if upload_ids.present? && context[:type] == :user && bot.persona.class.vision_enabled
|
|
|
|
context[:upload_ids] = upload_ids.compact
|
|
|
|
end
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
builder.push(**context)
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2025-03-06 09:41:09 +11:00
|
|
|
builder.to_a(style: style || (post.topic.private_message? ? :bot : :topic))
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
|
2024-11-29 06:26:48 +11:00
|
|
|
def title_playground(post, user)
|
2024-01-04 10:44:07 -03:00
|
|
|
context = conversation_context(post)
|
|
|
|
|
|
|
|
bot
|
2024-11-29 06:26:48 +11:00
|
|
|
.get_updated_title(context, post, user)
|
2024-01-04 10:44:07 -03:00
|
|
|
.tap do |new_title|
|
|
|
|
PostRevisor.new(post.topic.first_post, post.topic).revise!(
|
|
|
|
bot.bot_user,
|
|
|
|
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
|
|
|
|
)
|
|
|
|
end
|
2024-09-03 15:52:20 +10:00
|
|
|
|
|
|
|
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,
|
|
|
|
)
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
|
2024-05-21 17:17:02 +10:00
|
|
|
def chat_context(message, channel, persona_user, context_post_ids)
|
2024-05-06 09:49:02 +10:00
|
|
|
has_vision = bot.persona.class.vision_enabled
|
2024-05-08 18:44:04 +10:00
|
|
|
include_thread_titles = !channel.direct_message_channel? && !message.thread_id
|
|
|
|
|
|
|
|
current_id = message.id
|
|
|
|
if !channel.direct_message_channel?
|
|
|
|
# we are interacting via mentions ... strip mention
|
|
|
|
instruction_message = message.message.gsub(/@#{bot.bot_user.username}/i, "").strip
|
|
|
|
end
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2024-05-07 10:30:39 +10:00
|
|
|
messages = nil
|
2024-02-15 16:37:59 +11:00
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
max_messages = 40
|
|
|
|
if bot.persona.class.respond_to?(:max_context_posts)
|
|
|
|
max_messages = bot.persona.class.max_context_posts || 40
|
|
|
|
end
|
|
|
|
|
2024-05-07 10:30:39 +10:00
|
|
|
if !message.thread_id && channel.direct_message_channel?
|
|
|
|
messages = [message]
|
|
|
|
elsif !channel.direct_message_channel? && !message.thread_id
|
|
|
|
messages =
|
|
|
|
Chat::Message
|
2024-05-08 18:44:04 +10:00
|
|
|
.joins("left join chat_threads on chat_threads.id = chat_messages.thread_id")
|
|
|
|
.where(chat_channel_id: channel.id)
|
|
|
|
.where(
|
|
|
|
"chat_messages.thread_id IS NULL OR chat_threads.original_message_id = chat_messages.id",
|
|
|
|
)
|
2024-05-07 10:30:39 +10:00
|
|
|
.order(id: :desc)
|
|
|
|
.limit(max_messages)
|
|
|
|
.to_a
|
|
|
|
.reverse
|
|
|
|
end
|
|
|
|
|
|
|
|
messages ||=
|
2024-05-06 09:49:02 +10:00
|
|
|
ChatSDK::Thread.last_messages(
|
|
|
|
thread_id: message.thread_id,
|
|
|
|
guardian: Discourse.system_user.guardian,
|
|
|
|
page_size: max_messages,
|
|
|
|
)
|
|
|
|
|
|
|
|
builder = DiscourseAi::Completions::PromptMessagesBuilder.new
|
|
|
|
|
2024-05-21 17:17:02 +10:00
|
|
|
guardian = Guardian.new(message.user)
|
|
|
|
if context_post_ids
|
|
|
|
builder.set_chat_context_posts(context_post_ids, guardian, include_uploads: has_vision)
|
|
|
|
end
|
|
|
|
|
2024-05-07 10:30:39 +10:00
|
|
|
messages.each do |m|
|
2024-05-08 18:44:04 +10:00
|
|
|
# restore stripped message
|
|
|
|
m.message = instruction_message if m.id == current_id && instruction_message
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
if available_bot_user_ids.include?(m.user_id)
|
|
|
|
builder.push(type: :model, content: m.message)
|
|
|
|
else
|
|
|
|
upload_ids = nil
|
|
|
|
upload_ids = m.uploads.map(&:id) if has_vision && m.uploads.present?
|
2024-05-08 18:44:04 +10:00
|
|
|
mapped_message = m.message
|
|
|
|
|
|
|
|
thread_title = nil
|
|
|
|
thread_title = m.thread&.title if include_thread_titles && m.thread_id
|
|
|
|
mapped_message = "(#{thread_title})\n#{m.message}" if thread_title
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
builder.push(
|
|
|
|
type: :user,
|
2024-05-08 18:44:04 +10:00
|
|
|
content: mapped_message,
|
2024-05-06 09:49:02 +10:00
|
|
|
name: m.user.username,
|
|
|
|
upload_ids: upload_ids,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-21 17:17:02 +10:00
|
|
|
builder.to_a(
|
|
|
|
limit: max_messages,
|
|
|
|
style: channel.direct_message_channel? ? :chat_with_context : :chat,
|
|
|
|
)
|
2024-05-06 09:49:02 +10:00
|
|
|
end
|
|
|
|
|
2024-05-21 17:17:02 +10:00
|
|
|
def reply_to_chat_message(message, channel, context_post_ids)
|
2024-05-06 09:49:02 +10:00
|
|
|
persona_user = User.find(bot.persona.class.user_id)
|
|
|
|
|
|
|
|
participants = channel.user_chat_channel_memberships.map { |m| m.user.username }
|
|
|
|
|
2024-05-21 17:17:02 +10:00
|
|
|
context_post_ids = nil if !channel.direct_message_channel?
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
context =
|
|
|
|
get_context(
|
|
|
|
participants: participants.join(", "),
|
2024-05-21 17:17:02 +10:00
|
|
|
conversation_context: chat_context(message, channel, persona_user, context_post_ids),
|
2024-05-06 09:49:02 +10:00
|
|
|
user: message.user,
|
|
|
|
skip_tool_details: true,
|
|
|
|
)
|
|
|
|
|
|
|
|
reply = nil
|
|
|
|
guardian = Guardian.new(persona_user)
|
|
|
|
|
2024-05-08 18:44:04 +10:00
|
|
|
force_thread = message.thread_id.nil? && channel.direct_message_channel?
|
|
|
|
in_reply_to_id = channel.direct_message_channel? ? message.id : nil
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
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,
|
2024-05-08 18:44:04 +10:00
|
|
|
in_reply_to_id: in_reply_to_id,
|
|
|
|
force_thread: force_thread,
|
2024-05-07 10:30:39 +10:00
|
|
|
enforce_membership: !channel.direct_message_channel?,
|
2024-05-06 09:49:02 +10:00
|
|
|
)
|
|
|
|
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 get_context(participants:, conversation_context:, user:, skip_tool_details: nil)
|
|
|
|
result = {
|
2024-01-04 10:44:07 -03:00
|
|
|
site_url: Discourse.base_url,
|
|
|
|
site_title: SiteSetting.title,
|
|
|
|
site_description: SiteSetting.site_description,
|
|
|
|
time: Time.zone.now,
|
2024-05-06 09:49:02 +10:00
|
|
|
participants: participants,
|
|
|
|
conversation_context: conversation_context,
|
|
|
|
user: user,
|
2024-01-04 10:44:07 -03:00
|
|
|
}
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
result[:skip_tool_details] = true if skip_tool_details
|
|
|
|
|
|
|
|
result
|
|
|
|
end
|
|
|
|
|
2025-03-06 09:41:09 +11:00
|
|
|
def reply_to(post, custom_instructions: nil, whisper: nil, context_style: nil, &blk)
|
2024-10-30 10:28:20 +11:00
|
|
|
# this is a multithreading issue
|
|
|
|
# post custom prompt is needed and it may not
|
|
|
|
# be properly loaded, ensure it is loaded
|
|
|
|
PostCustomPrompt.none
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
reply = +""
|
2024-11-19 09:22:39 +11:00
|
|
|
post_streamer = nil
|
2024-05-06 09:49:02 +10:00
|
|
|
|
|
|
|
post_type =
|
2025-03-06 09:41:09 +11:00
|
|
|
(
|
|
|
|
if (whisper || post.post_type == Post.types[:whisper])
|
|
|
|
Post.types[:whisper]
|
|
|
|
else
|
|
|
|
Post.types[:regular]
|
|
|
|
end
|
|
|
|
)
|
2024-05-06 09:49:02 +10:00
|
|
|
|
|
|
|
context =
|
|
|
|
get_context(
|
|
|
|
participants: post.topic.allowed_users.map(&:username).join(", "),
|
2025-03-06 09:41:09 +11:00
|
|
|
conversation_context: conversation_context(post, style: context_style),
|
2024-05-06 09:49:02 +10:00
|
|
|
user: post.user,
|
|
|
|
)
|
|
|
|
context[:post_id] = post.id
|
|
|
|
context[:topic_id] = post.topic_id
|
2024-05-07 21:55:46 +10:00
|
|
|
context[:private_message] = post.topic.private_message?
|
2024-11-05 07:43:26 +11:00
|
|
|
context[:custom_instructions] = custom_instructions
|
2024-05-06 09:49:02 +10:00
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
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?
|
|
|
|
|
|
|
|
# we need to ensure persona user is allowed to reply to the pm
|
|
|
|
if post.topic.private_message?
|
|
|
|
if !post.topic.topic_allowed_users.exists?(user_id: reply_user.id)
|
|
|
|
post.topic.topic_allowed_users.create!(user_id: reply_user.id)
|
|
|
|
end
|
|
|
|
end
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
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,
|
|
|
|
)
|
2024-01-09 23:20:28 +11:00
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
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
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2024-06-11 18:14:14 +10:00
|
|
|
context[:skip_tool_details] ||= !bot.persona.class.tool_details
|
|
|
|
|
2024-11-19 09:22:39 +11:00
|
|
|
post_streamer = PostStreamer.new(delay: Rails.env.test? ? 0 : 0.5) if stream_reply
|
|
|
|
|
2025-03-04 12:22:30 +11:00
|
|
|
started_thinking = false
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
new_custom_prompts =
|
2024-10-30 10:28:20 +11:00
|
|
|
bot.reply(context) do |partial, cancel, placeholder, type|
|
2025-03-04 12:22:30 +11:00
|
|
|
if type == :thinking && !started_thinking
|
|
|
|
reply << "<details><summary>#{I18n.t("discourse_ai.ai_bot.thinking")}</summary>"
|
|
|
|
started_thinking = true
|
|
|
|
end
|
|
|
|
|
|
|
|
if type != :thinking && started_thinking
|
|
|
|
reply << "</details>\n\n"
|
|
|
|
started_thinking = false
|
|
|
|
end
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
reply << partial
|
|
|
|
raw = reply.dup
|
2024-11-14 06:58:24 +11:00
|
|
|
raw << "\n\n" << placeholder if placeholder.present?
|
2024-01-04 10:44:07 -03:00
|
|
|
|
DEV: artifact system update (#1096)
### Why
This pull request fundamentally restructures how AI bots create and update web artifacts to address critical limitations in the previous approach:
1. **Improved Artifact Context for LLMs**: Previously, artifact creation and update tools included the *entire* artifact source code directly in the tool arguments. This overloaded the Language Model (LLM) with raw code, making it difficult for the LLM to maintain a clear understanding of the artifact's current state when applying changes. The LLM would struggle to differentiate between the base artifact and the requested modifications, leading to confusion and less effective updates.
2. **Reduced Token Usage and History Bloat**: Including the full artifact source code in every tool interaction was extremely token-inefficient. As conversations progressed, this redundant code in the history consumed a significant number of tokens unnecessarily. This not only increased costs but also diluted the context for the LLM with less relevant historical information.
3. **Enabling Updates for Large Artifacts**: The lack of a practical diff or targeted update mechanism made it nearly impossible to efficiently update larger web artifacts. Sending the entire source code for every minor change was both computationally expensive and prone to errors, effectively blocking the use of AI bots for meaningful modifications of complex artifacts.
**This pull request addresses these core issues by**:
* Introducing methods for the AI bot to explicitly *read* and understand the current state of an artifact.
* Implementing efficient update strategies that send *targeted* changes rather than the entire artifact source code.
* Providing options to control the level of artifact context included in LLM prompts, optimizing token usage.
### What
The main changes implemented in this PR to resolve the above issues are:
1. **`Read Artifact` Tool for Contextual Awareness**:
- A new `read_artifact` tool is introduced, enabling AI bots to fetch and process the current content of a web artifact from a given URL (local or external).
- This provides the LLM with a clear and up-to-date representation of the artifact's HTML, CSS, and JavaScript, improving its understanding of the base to be modified.
- By cloning local artifacts, it allows the bot to work with a fresh copy, further enhancing context and control.
2. **Refactored `Update Artifact` Tool with Efficient Strategies**:
- The `update_artifact` tool is redesigned to employ more efficient update strategies, minimizing token usage and improving update precision:
- **`diff` strategy**: Utilizes a search-and-replace diff algorithm to apply only the necessary, targeted changes to the artifact's code. This significantly reduces the amount of code sent to the LLM and focuses its attention on the specific modifications.
- **`full` strategy**: Provides the option to replace the entire content sections (HTML, CSS, JavaScript) when a complete rewrite is required.
- Tool options enhance the control over the update process:
- `editor_llm`: Allows selection of a specific LLM for artifact updates, potentially optimizing for code editing tasks.
- `update_algorithm`: Enables choosing between `diff` and `full` update strategies based on the nature of the required changes.
- `do_not_echo_artifact`: Defaults to true, and by *not* echoing the artifact in prompts, it further reduces token consumption in scenarios where the LLM might not need the full artifact context for every update step (though effectiveness might be slightly reduced in certain update scenarios).
3. **System and General Persona Tool Option Visibility and Customization**:
- Tool options, including those for system personas, are made visible and editable in the admin UI. This allows administrators to fine-tune the behavior of all personas and their tools, including setting specific LLMs or update algorithms. This was previously limited or hidden for system personas.
4. **Centralized and Improved Content Security Policy (CSP) Management**:
- The CSP for AI artifacts is consolidated and made more maintainable through the `ALLOWED_CDN_SOURCES` constant. This improves code organization and future updates to the allowed CDN list, while maintaining the existing security posture.
5. **Codebase Improvements**:
- Refactoring of diff utilities, introduction of strategy classes, enhanced error handling, new locales, and comprehensive testing all contribute to a more robust, efficient, and maintainable artifact management system.
By addressing the issues of LLM context confusion, token inefficiency, and the limitations of updating large artifacts, this pull request significantly improves the practicality and effectiveness of AI bots in managing web artifacts within Discourse.
2025-02-04 16:27:27 +11:00
|
|
|
if blk && type != :tool_details && type != :partial_tool && type != :partial_invoke
|
|
|
|
blk.call(partial)
|
|
|
|
end
|
2024-10-30 10:28:20 +11:00
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
if stream_reply && !Discourse.redis.get(redis_stream_key)
|
2024-01-04 10:44:07 -03:00
|
|
|
cancel&.call
|
|
|
|
reply_post.update!(raw: reply, cooked: PrettyText.cook(reply))
|
2024-11-21 17:51:45 +11:00
|
|
|
# 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
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
|
2024-11-19 09:22:39 +11:00
|
|
|
if post_streamer
|
|
|
|
post_streamer.run_later do
|
|
|
|
Discourse.redis.expire(redis_stream_key, 60)
|
|
|
|
publish_update(reply_post, { raw: raw })
|
2024-02-15 16:37:59 +11:00
|
|
|
end
|
|
|
|
end
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
|
|
|
|
return if reply.blank?
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
if stream_reply
|
2024-11-19 09:22:39 +11:00
|
|
|
post_streamer.finish
|
|
|
|
post_streamer = nil
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
# land the final message prior to saving so we don't clash
|
|
|
|
reply_post.cooked = PrettyText.cook(reply)
|
|
|
|
publish_final_update(reply_post)
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
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,
|
|
|
|
)
|
|
|
|
end
|
2024-01-04 10:44:07 -03:00
|
|
|
|
2025-03-04 12:22:30 +11:00
|
|
|
# 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
|
2024-01-06 05:21:14 +11:00
|
|
|
reply_post.post_custom_prompt ||= reply_post.build_post_custom_prompt(custom_prompt: [])
|
|
|
|
prompt = reply_post.post_custom_prompt.custom_prompt || []
|
2024-01-04 10:44:07 -03:00
|
|
|
prompt.concat(new_custom_prompts)
|
2024-01-06 05:21:14 +11:00
|
|
|
reply_post.post_custom_prompt.update!(custom_prompt: prompt)
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
2024-01-06 05:21:14 +11:00
|
|
|
|
2024-12-10 05:59:19 +11:00
|
|
|
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
|
2024-03-04 09:56:59 +11:00
|
|
|
# since we are skipping validations and jobs we
|
|
|
|
# may need to fix participant count
|
2024-12-10 05:59:19 +11:00
|
|
|
if reply_post && reply_post.topic && reply_post.topic.private_message? &&
|
|
|
|
reply_post.topic.participant_count < 2
|
2024-03-04 09:56:59 +11:00
|
|
|
reply_post.topic.update!(participant_count: 2)
|
|
|
|
end
|
2024-11-19 09:22:39 +11:00
|
|
|
post_streamer&.finish(skip_callback: true)
|
2024-02-15 16:37:59 +11:00
|
|
|
publish_final_update(reply_post) if stream_reply
|
2024-09-03 15:52:20 +10:00
|
|
|
if reply_post && post.post_number == 1 && post.topic.private_message?
|
2024-11-29 06:26:48 +11:00
|
|
|
title_playground(reply_post, post.user)
|
2024-09-03 15:52:20 +10:00
|
|
|
end
|
2024-01-04 10:44:07 -03:00
|
|
|
end
|
|
|
|
|
2024-03-08 06:37:23 +11:00
|
|
|
def available_bot_usernames
|
|
|
|
@bot_usernames ||=
|
2024-06-18 14:32:14 -03:00
|
|
|
AiPersona.joins(:user).pluck(:username).concat(available_bot_users.map(&:username))
|
2024-03-08 06:37:23 +11:00
|
|
|
end
|
|
|
|
|
2024-05-06 09:49:02 +10:00
|
|
|
def available_bot_user_ids
|
2024-06-18 14:32:14 -03:00
|
|
|
@bot_ids ||= AiPersona.joins(:user).pluck("users.id").concat(available_bot_users.map(&:id))
|
2024-05-06 09:49:02 +10:00
|
|
|
end
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
private
|
|
|
|
|
2024-06-18 14:32:14 -03:00
|
|
|
def available_bot_users
|
|
|
|
@available_bots ||=
|
|
|
|
User.joins("INNER JOIN llm_models llm ON llm.user_id = users.id").where(active: true)
|
|
|
|
end
|
|
|
|
|
2024-01-15 18:51:14 +11:00
|
|
|
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
|
|
|
|
|
2024-01-04 10:44:07 -03:00
|
|
|
def can_attach?(post)
|
|
|
|
return false if bot.bot_user.nil?
|
2024-02-15 16:37:59 +11:00
|
|
|
return false if post.topic.private_message? && post.post_type != Post.types[:regular]
|
2024-01-04 10:44:07 -03:00
|
|
|
return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank?
|
2024-10-30 20:24:39 +11:00
|
|
|
return false if post.custom_fields[BYPASS_AI_REPLY_CUSTOM_FIELD].present?
|
2024-01-04 10:44:07 -03:00
|
|
|
|
|
|
|
true
|
|
|
|
end
|
|
|
|
|
2024-02-15 16:37:59 +11:00
|
|
|
def schedule_bot_reply(post)
|
|
|
|
persona_id =
|
|
|
|
DiscourseAi::AiBot::Personas::Persona.system_personas[bot.persona.class] ||
|
|
|
|
bot.persona.class.id
|
|
|
|
::Jobs.enqueue(
|
|
|
|
:create_ai_reply,
|
2024-01-04 10:44:07 -03:00
|
|
|
post_id: post.id,
|
2024-02-15 16:37:59 +11:00
|
|
|
bot_user_id: bot.bot_user.id,
|
|
|
|
persona_id: persona_id,
|
2024-01-04 10:44:07 -03:00
|
|
|
)
|
|
|
|
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)
|
2024-01-15 18:51:14 +11:00
|
|
|
payload = { post_id: bot_reply_post.id, post_number: bot_reply_post.post_number }.merge(
|
|
|
|
payload,
|
|
|
|
)
|
2024-01-04 10:44:07 -03:00
|
|
|
MessageBus.publish(
|
|
|
|
"discourse-ai/ai-bot/topic/#{bot_reply_post.topic_id}",
|
2024-01-15 18:51:14 +11:00
|
|
|
payload,
|
2024-01-04 10:44:07 -03:00
|
|
|
user_ids: bot_reply_post.topic.allowed_user_ids,
|
2024-01-15 18:51:14 +11:00
|
|
|
max_backlog_size: 2,
|
|
|
|
max_backlog_age: 60,
|
2024-01-04 10:44:07 -03:00
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|