mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-02-14 07:24:51 +00:00
bab5e52e38
This commit uses a new plugin modifier introduced in https://github.com/discourse/discourse/pull/26508 to mark all uploads as _not_ secure in shared PM AI conversations. This is so images created by the AI bot (or uploaded by the user) do not end up as broken URLs because of the security requirements around them. This relies on the UpdateTopicUploadSecurity job in core as well, which is fired when an AI conversation is shared or deleted.
216 lines
6.1 KiB
Ruby
216 lines
6.1 KiB
Ruby
# 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)
|
|
|
|
conversation =
|
|
if conversation
|
|
conversation.update(**conversation_data)
|
|
conversation
|
|
else
|
|
create(user_id: user.id, target: target, **conversation_data)
|
|
end
|
|
|
|
::Jobs.enqueue(:shared_conversation_adjust_upload_security, conversation_id: conversation.id)
|
|
|
|
conversation
|
|
end
|
|
|
|
def self.destroy_conversation(conversation)
|
|
conversation.destroy
|
|
::Jobs.enqueue(
|
|
:shared_conversation_adjust_upload_security,
|
|
target_id: conversation.target_id,
|
|
target_type: conversation.target_type,
|
|
)
|
|
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 << "<p><b>#{post.user.username}</b>: #{text}</p>"
|
|
if html.length > 1000
|
|
html << "<p>...</p>"
|
|
break
|
|
end
|
|
end
|
|
html << "<a href='#{url}'>#{I18n.t("discourse_ai.share_ai.read_more")}<a>"
|
|
html
|
|
end
|
|
|
|
def onebox
|
|
<<~HTML
|
|
<div>
|
|
<aside class="onebox allowlistedgeneric" data-onebox-src="#{url}">
|
|
<header class="source">
|
|
<span class="onebox-ai-llm-title">#{I18n.t("discourse_ai.share_ai.onebox_title", llm_name: llm_name)}</span>
|
|
<a href="#{url}" target="_blank" rel="nofollow ugc noopener" tabindex="-1">#{Discourse.base_uri}</a>
|
|
</header>
|
|
<article class="onebox-body">
|
|
<h3><a href="#{url}" rel="nofollow ugc noopener" tabindex="-1">#{title}</a></h3>
|
|
#{html_excerpt}
|
|
</article>
|
|
<div style="clear: both"></div>
|
|
</aside>
|
|
</div>
|
|
HTML
|
|
end
|
|
|
|
def self.excerpt(posts)
|
|
excerpt = +""
|
|
posts.each do |post|
|
|
excerpt << "#{post.user.display_name}: #{post.excerpt(100)} "
|
|
break if excerpt.length > 1000
|
|
end
|
|
excerpt
|
|
end
|
|
|
|
def formatted_excerpt
|
|
I18n.t("discourse_ai.share_ai.formatted_excerpt", llm_name: llm_name, excerpt: excerpt)
|
|
end
|
|
|
|
def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_usernames: false)
|
|
llm_name = nil
|
|
topic.topic_allowed_users.each do |tu|
|
|
if DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(tu.user_id)
|
|
llm_name = DiscourseAi::AiBot::EntryPoint.find_bot_by_id(tu.user_id)&.llm
|
|
end
|
|
end
|
|
|
|
llm_name = ActiveSupport::Inflector.humanize(llm_name) if llm_name
|
|
llm_name ||= I18n.t("discourse_ai.unknown_model")
|
|
|
|
persona = nil
|
|
if persona_id = topic.custom_fields["ai_persona_id"]
|
|
persona = AiPersona.find_by(id: persona_id.to_i)&.name
|
|
end
|
|
|
|
posts =
|
|
topic
|
|
.posts
|
|
.by_post_number
|
|
.where(post_type: Post.types[:regular])
|
|
.where.not(cooked: nil)
|
|
.where(deleted_at: nil)
|
|
.limit(max_posts)
|
|
|
|
{
|
|
llm_name: llm_name,
|
|
title: topic.title,
|
|
excerpt: excerpt(posts),
|
|
context:
|
|
posts.map do |post|
|
|
mapped = {
|
|
id: post.id,
|
|
user_id: post.user_id,
|
|
created_at: post.created_at,
|
|
cooked: post.cooked,
|
|
}
|
|
|
|
mapped[:persona] = persona if ::DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(
|
|
post.user_id,
|
|
)
|
|
mapped[:username] = post.user&.username if include_usernames
|
|
mapped
|
|
end,
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def populate_user_info!(posts)
|
|
users = User.where(id: posts.map(&:user_id).uniq).map { |u| [u.id, u] }.to_h
|
|
posts.each { |post| post.user = users[post.user_id] }
|
|
end
|
|
|
|
def generate_share_key
|
|
self.share_key = SecureRandom.urlsafe_base64(16)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: shared_ai_conversations
|
|
#
|
|
# id :bigint not null, primary key
|
|
# user_id :integer not null
|
|
# target_id :integer not null
|
|
# target_type :string not null
|
|
# title :string not null
|
|
# llm_name :string not null
|
|
# context :jsonb not null
|
|
# share_key :string not null
|
|
# excerpt :string not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_shared_ai_conversations_user_target (user_id,target_id,target_type) UNIQUE
|
|
# index_shared_ai_conversations_on_share_key (share_key) UNIQUE
|
|
# index_shared_ai_conversations_on_target_id_and_target_type (target_id,target_type) UNIQUE
|
|
#
|