FEATURE: Share conversations with AI via a URL (#521)

This allows users to share a static page of an AI conversation with
the rest of the world.

By default this feature is disabled, it is enabled by turning on
ai_bot_allow_public_sharing via site settings

Precautions are taken when sharing

1. We make a carbonite copy
2. We minimize work generating page
3. We limit to 100 interactions
4. Many security checks - including disallowing if there is a mix
of users in the PM.

* Bonus commit, large PRs like this PR did not work with github tool
large objects would destroy context


Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
Sam 2024-03-12 16:51:41 +11:00 committed by GitHub
parent 740731ab53
commit a03bc6ddec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2434 additions and 8 deletions

View File

@ -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

View File

@ -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")

View File

@ -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 << "<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
#

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<title><%= @shared_conversation.title %></title>
<meta property="og:title" content="<%= @shared_conversation.title %>">
<meta property="og:description" content="<%= @shared_conversation.formatted_excerpt %>">
<meta property="og:type" content="website">
<meta property="og:url" content="<%= request.original_url %>">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<%= @shared_conversation.title %>">
<meta name="twitter:description" content="<%= @shared_conversation.formatted_excerpt %>">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="<%= ::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/share.css") %>">
<link rel="stylesheet" href="<%= ::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/highlight.min.css") %>">
<script>
window.hljs_initHighlighting = function() {
document.querySelectorAll('pre code').forEach((el) => {
hljs.highlightElement(el);
});
};
</script>
<script async src="<%= ::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/highlight.min.js") %>" async onload="window.hljs_initHighlighting()" ></script>
</head>
<body>
<div class="container">
<div class="header">
<a href="https://discourse.org/ai" class="ai-logo">
<img src="<%= ::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/discourse_ai.png") %>" alt="AI Share" class="logo" width=193 height=61>
</a>
<a class="site-title" href="<%= Discourse.base_url %>">
<%= SiteSetting.title %>
</a>
<div class="llm-name">
<span><%= @shared_conversation.llm_name %></span>
</div>
</div>
<h2><%= @shared_conversation.title %></h2>
<% @shared_conversation.populated_context.each do |post| %>
<div class="post">
<div class="post-header">
<span class="post-user"><%= post.user.username %></span>
<%if post.persona.present? %>
<span class="post-persona"><%= post.persona %></span>
<% end %>
<span class="post-date"><%= post.created_at.strftime('%Y-%m-%d') %></span>
</div>
<div class="post-content">
<%= post.cooked.html_safe %>
</div>
</div>
<% end %>
</div>
</body>
</html>

View File

@ -0,0 +1,121 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { clipboardCopyAsync } from "discourse/lib/utilities";
import i18n from "discourse-common/helpers/i18n";
import { getAbsoluteURL } from "discourse-common/lib/get-url";
import I18n from "discourse-i18n";
export default class ShareModal extends Component {
@service toasts;
@tracked shareKey = "";
constructor() {
super(...arguments);
this.shareKey = this.args.model.share_key;
}
get htmlContext() {
let context = [];
this.args.model.context.forEach((post) => {
context.push(`<p><b>${post.username}:</b></p>`);
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"),
},
});
}
<template>
<DModal
class="ai-share-full-topic-modal"
@title={{i18n "discourse_ai.ai_bot.share_full_topic_modal.title"}}
@closeModal={{@closeModal}}
>
<:body>
<div class="ai-share-full-topic-modal__body">
{{this.htmlContext}}
</div>
</:body>
<:footer>
<DButton
class="btn-primary confirm"
@icon="copy"
@action={{this.share}}
@label={{this.primaryLabel}}
/>
{{#if this.shareKey}}
<DButton
class="btn-danger"
@icon="far-trash-alt"
@action={{this.deleteLink}}
@label="discourse_ai.ai_bot.share_full_topic_modal.delete"
/>
{{/if}}
</:footer>
</DModal>
</template>
}

View File

@ -3,15 +3,20 @@ import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import i18n from "discourse-common/helpers/i18n";
import discourseLater from "discourse-common/lib/later";
import I18n from "discourse-i18n";
import { showShareConversationModal } from "../../lib/ai-bot-helper";
import copyConversation from "../../lib/copy-conversation";
export default class ShareModal extends Component {
@service modal;
@service siteSettings;
@service currentUser;
@tracked contextValue = 1;
@tracked htmlContext = "";
@tracked maxContext = 0;
@ -71,6 +76,14 @@ export default class ShareModal extends Component {
}, 2000);
}
@action
shareConversationModal(event) {
event?.preventDefault();
this.args.closeModal();
showShareConversationModal(this.modal, this.args.model.topic_id);
return false;
}
<template>
<DModal
class="ai-share-modal"
@ -104,6 +117,13 @@ export default class ShareModal extends Component {
@label="discourse_ai.ai_bot.share_modal.copy"
/>
<span class="ai-share-modal__just-copied">{{this.justCopiedText}}</span>
{{#if this.currentUser.can_share_ai_bot_conversations}}
<a href {{on "click" this.shareConversationModal}}>
<span class="ai-share-modal__share-tip">
{{i18n "discourse_ai.ai_bot.share_modal.share_tip"}}
</span>
</a>
{{/if}}
</:footer>
</DModal>
</template>

View File

@ -0,0 +1,5 @@
export default function () {
this.route("discourse-ai-shared-conversation-show", {
path: "/discourse-ai/ai-bot/shared-ai-conversations/:share_key",
});
}

View File

@ -1,5 +1,16 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Composer from "discourse/models/composer";
import I18n from "I18n";
import ShareFullTopicModal from "../components/modal/share-full-topic-modal";
export function showShareConversationModal(modal, topicId) {
ajax(`/discourse-ai/ai-bot/shared-ai-conversations/preview/${topicId}.json`)
.then((payload) => {
modal.show(ShareFullTopicModal, { model: payload });
})
.catch(popupAjaxError);
}
export function composeAiBotMessage(targetBot, composer) {
const currentUser = composer.currentUser;

View File

@ -0,0 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
beforeModel(transition) {
window.location = transition.intent.url;
transition.abort();
},
});

View File

@ -8,6 +8,7 @@ import streamText from "../discourse/lib/ai-streamer";
import copyConversation from "../discourse/lib/copy-conversation";
const AUTO_COPY_THRESHOLD = 4;
import AiBotHeaderIcon from "../discourse/components/ai-bot-header-icon";
import { showShareConversationModal } from "../discourse/lib/ai-bot-helper";
let enabledChatBotIds = [];
function isGPTBot(user) {
@ -145,6 +146,29 @@ function initializeShareButton(api) {
const modal = api.container.lookup("service:modal");
}
function initializeShareTopicButton(api) {
const modal = api.container.lookup("service:modal");
const currentUser = api.container.lookup("current-user:main");
api.registerTopicFooterButton({
id: "share-ai-conversation",
icon: "share-alt",
label: "discourse_ai.ai_bot.share_ai_conversation.name",
title: "discourse_ai.ai_bot.share_ai_conversation.title",
action() {
showShareConversationModal(modal, this.topic.id);
},
classNames: ["share-ai-conversation-button"],
dependentKeys: ["topic.ai_persona_name"],
displayed() {
return (
currentUser?.can_share_ai_bot_conversations &&
this.topic.ai_persona_name !== undefined
);
},
});
}
export default {
name: "discourse-ai-bot-replies",
@ -157,6 +181,9 @@ export default {
withPluginApi("1.6.0", initializeAIBotReplies);
withPluginApi("1.6.0", initializePersonaDecorator);
withPluginApi("1.22.0", (api) => initializeShareButton(api, container));
withPluginApi("1.22.0", (api) =>
initializeShareTopicButton(api, container)
);
}
},
};

View File

@ -137,3 +137,7 @@ details.ai-quote {
color: var(--success);
}
}
span.onebox-ai-llm-title {
font-weight: bold;
}

View File

@ -222,13 +222,24 @@ en:
share: "Share AI conversation"
conversation_shared: "Conversation copied"
share_full_topic_modal:
title: "Share Conversation Publicly"
share: "Share and Copy Link"
update: "Update and Copy Link"
delete: "Delete Share"
share_ai_conversation:
name: "Share AI Conversation"
title: "Share this AI conversation publicly"
ai_label: "AI"
ai_title: "Conversation with AI"
share_modal:
title: "Share AI conversation"
title: "Copy AI conversation"
copy: "Copy"
context: "Interactions to share:"
share_tip: Alternatively, you can share the entire conversation.
bot_names:
fake: "Fake Test Bot"

View File

@ -95,6 +95,7 @@ en:
ai_bot_enabled: "Enable the AI Bot module."
ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning"
ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups."
ai_bot_public_sharing_allowed_groups: "Allow these groups to share AI personal messages with the public via a unique publicly available link"
ai_bot_enabled_chat_bots: "Available models to act as an AI Bot"
ai_bot_add_to_header: "Display a button in the header to start a PM with a AI Bot"
ai_bot_github_access_token: "GitHub access token for use with GitHub AI tools (required for search support)"
@ -134,6 +135,8 @@ en:
yaxis:
discourse_ai:
unknown_model: "Unknown AI model"
ai_helper:
errors:
completion_request_failed: "Something went wrong while trying to provide suggestions. Please, try again."
@ -152,6 +155,16 @@ en:
image_caption:
attribution: "Captioned by AI"
share_ai:
read_more: "Read full transcript"
onebox_title: "AI Conversation with %{llm_name}"
errors:
not_allowed: "You are not allowed to share this topic"
other_people_in_pm: "Personal messages with other humans cannot be shared publicly"
other_content_in_pm: "Personal messages containing posts from other people cannot be shared publicly"
failed_to_share: "Failed to share the conversation"
conversation_deleted: "Conversation share deleted successfully"
formatted_excerpt: "AI Conversation with %{llm_name}:\n %{excerpt}"
ai_bot:
personas:
cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead"

View File

@ -19,6 +19,13 @@ DiscourseAi::Engine.routes.draw do
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
get "bot-username" => "bot#show_bot_username"
end
scope module: :ai_bot, path: "/ai-bot/shared-ai-conversations" do
post "/" => "shared_ai_conversations#create"
delete "/:share_key" => "shared_ai_conversations#destroy"
get "/:share_key" => "shared_ai_conversations#show"
get "/preview/:topic_id" => "shared_ai_conversations#preview"
end
end
Discourse::Application.routes.draw do

View File

@ -311,6 +311,13 @@ discourse_ai:
list_type: compact
default: "3|14" # 3: @staff, 14: @trust_level_4
# Adding a new bot? Make sure to create a user for it on the seed file and update translations.
ai_bot_public_sharing_allowed_groups:
client: false
type: group_list
list_type: compact
default: "1|2" # 1: admins, 2: moderators
allow_any: false
refresh: true
ai_bot_enabled_chat_bots:
type: list
default: "gpt-3.5-turbo"

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class AddSharedAiConversations < ActiveRecord::Migration[7.0]
def up
create_table :shared_ai_conversations do |t|
t.integer :user_id, null: false
t.integer :target_id, null: false
t.string :target_type, null: false, max_length: 100
t.string :title, null: false, max_length: 1024
t.string :llm_name, null: false, max_length: 1024
t.jsonb :context, null: false
t.string :share_key, null: false, index: { unique: true }
t.string :excerpt, null: false, max_length: 10_000
t.timestamps
end
add_index :shared_ai_conversations, %i[target_id target_type], unique: true
add_index :shared_ai_conversations,
%i[user_id target_id target_type],
unique: true,
name: "idx_shared_ai_conversations_user_target"
end
def down
drop_table :shared_ai_conversations
end
end

View File

@ -31,6 +31,14 @@ module DiscourseAi
BOT_USER_IDS = BOTS.map(&:first)
Bot = Struct.new(:id, :name, :llm)
def self.find_bot_by_id(id)
found = DiscourseAi::AiBot::EntryPoint::BOTS.find { |bot| bot[0] == id }
return if !found
Bot.new(found[0], found[1], found[2])
end
def self.map_bot_model_to_user_id(model_name)
case model_name
in "gpt-4-turbo"
@ -56,6 +64,28 @@ module DiscourseAi
end
end
# Most errors are simply "not_allowed"
# we do not want to reveal information about this sytem
# the 2 exceptions are "other_people_in_pm" and "other_content_in_pm"
# in both cases you have access to the PM so we are not revealing anything
def self.ai_share_error(topic, guardian)
return nil if guardian.can_share_ai_bot_conversation?(topic)
return :not_allowed if !guardian.can_see?(topic)
# other people in PM
if topic.topic_allowed_users.where("user_id > 0 and user_id <> ?", guardian.user.id).exists?
return :other_people_in_pm
end
# other content in PM
if topic.posts.where("user_id > 0 and user_id <> ?", guardian.user.id).exists?
return :other_content_in_pm
end
:not_allowed
end
def inject_into(plugin)
plugin.on(:site_setting_changed) do |name, _old_value, _new_value|
if name == :ai_bot_enabled_chat_bots || name == :ai_bot_enabled ||
@ -64,6 +94,20 @@ module DiscourseAi
end
end
Oneboxer.register_local_handler(
"discourse_ai/ai_bot/shared_ai_conversations",
) do |url, route|
if route[:action] == "show" && share_key = route[:share_key]
if conversation = SharedAiConversation.find_by(share_key: share_key)
conversation.onebox
end
end
end
plugin.on(:reduce_excerpt) do |doc, options|
doc.css("details").remove if options && options[:strip_details]
end
plugin.register_seedfu_fixtures(
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
)
@ -130,6 +174,10 @@ module DiscourseAi
scope.user.in_any_groups?(SiteSetting.ai_helper_custom_prompts_allowed_groups_map)
end
plugin.add_to_serializer(:current_user, :can_share_ai_bot_conversations) do
scope.user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map)
end
plugin.register_svg_icon("robot")
plugin.add_to_serializer(

View File

@ -314,7 +314,7 @@ module DiscourseAi
post.topic.save_custom_fields
::Jobs.enqueue_in(
5.minutes,
1.minute,
:update_ai_bot_pm_title,
post_id: post.id,
bot_user_id: bot.bot_user.id,

View File

@ -4,6 +4,8 @@ module DiscourseAi
module AiBot
module Tools
class GithubPullRequestDiff < Tool
LARGE_OBJECT_THRESHOLD = 30_000
def self.signature
{
name: name,
@ -56,6 +58,7 @@ module DiscourseAi
if response.code == "200"
diff = response.body
diff = sort_and_shorten_diff(diff)
diff = truncate(diff, max_length: 20_000, percent_length: 0.3, llm: llm)
{ diff: diff }
else
@ -66,6 +69,46 @@ module DiscourseAi
def description_args
{ repo: repo, pull_id: pull_id, url: url }
end
private
def sort_and_shorten_diff(diff, threshold: LARGE_OBJECT_THRESHOLD)
# This regex matches the start of a new file in the diff,
# capturing the file paths for later use.
file_start_regex = /^diff --git.*/
prev_start = 0
prev_match = nil
split = []
diff.scan(file_start_regex) do |match|
match_start = $~.offset(0)[0] # Get the start position of this match
if prev_start != 0
full_diff = diff[prev_start...match_start]
split << [prev_match, full_diff]
end
prev_match = match
prev_start = match_start
end
split << [prev_match, diff[prev_start..-1]] if prev_match
split.sort! { |x, y| x[1].length <=> y[1].length }
split
.map do |x, y|
if y.length < threshold
y
else
"#{x}\nRedacted, Larger than #{threshold} chars"
end
end
.join("\n")
end
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module DiscourseAi
module GuardianExtensions
def can_share_ai_bot_conversation?(target)
return false if anonymous?
if !SiteSetting.discourse_ai_enabled || !SiteSetting.ai_bot_enabled ||
!SiteSetting.ai_bot_public_sharing_allowed_groups_map.any?
return false
end
return false if !user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map)
# In future we may add other valid targets for AI conversation sharing,
# for now we only support topics.
if target.is_a?(Topic)
return false if !target.private_message?
return false if target.topic_allowed_groups.exists?
return false if !target.topic_allowed_users.exists?(user_id: user.id)
# other people in PM
if target.topic_allowed_users.where("user_id > 0 and user_id <> ?", user.id).exists?
return false
end
# other content in PM
return false if target.posts.where("user_id > 0 and user_id <> ?", user.id).exists?
end
true
end
def can_destroy_shared_ai_bot_conversation?(conversation)
return false if anonymous?
conversation.user_id == user.id || is_admin?
end
end
end

View File

@ -61,4 +61,6 @@ after_initialize do
require_relative "spec/support/embeddings_generation_stubs"
require_relative "spec/support/stable_diffusion_stubs"
end
reloadable_patch { |plugin| Guardian.prepend DiscourseAi::GuardianExtensions }
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

9
public/ai-share/highlight.min.css vendored Normal file
View File

@ -0,0 +1,9 @@
/*!
Theme: Default
Description: Original highlight.js style
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
Maintainer: @highlightjs/core-team
Website: https://highlightjs.org/
License: see project LICENSE
Touched: 2021
*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f0f0f0;color:#444}.hljs-comment{color:#888}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#bc6060}.hljs-literal{color:#78a960}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#4d99bf}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}

1120
public/ai-share/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

292
public/ai-share/share.css Normal file
View File

@ -0,0 +1,292 @@
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--background-color: #f8f9fa;
--post-background-color: #ffffff;
--details-background-color: #e9ecef;
--details-hover-color: #dee2e6;
}
body {
font-family: Söhne, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, Helvetica Neue, Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
margin: 0;
padding: 20px;
font-size: 18px;
line-height: 1.8;
background-color: var(--background-color);
color: #212529;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #ffffff;
color: #ffffff;
padding: 0.5rem 1rem 0 0.1rem;
}
.ai-logo .logo {
height: 61px;
width: 193px;
}
.site-title {
font-size: 1.4em;
font-weight: 600;
letter-spacing: 0.02em;
color: #060606;
padding: 0 1em;
transition: color 0.3s ease;
text-decoration: none;
}
.llm-name {
font-size: 1em;
background-color: #0056b3;
color: #ffffff;
padding: 0.5em 1em;
border-radius: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
text-decoration: none;
}
pre, code {
font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace;
font-size: 0.9em;
background-color: #f0f0f0;
}
pre {
padding: 1em;
border-radius: 6px;
overflow-x: auto;
}
code {
padding: 0.2em 0.4em;
border-radius: 4px;
}
pre code {
font-size: inherit;
padding: 0;
background-color: transparent;
}
h1 h2 h3 h4 h5 h6 {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 2em;
color: #333;
margin-bottom: 0.5em;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 3rem;
}
.post {
margin-bottom: 3rem;
padding: 2em;
background-color: var(--post-background-color);
border-radius: 8px;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
}
.post-header {
display: flex;
align-items: baseline;
justify-content: start;
margin-bottom: 1rem;
}
.post-user, .post-persona, .post-date {
display: block;
margin-top: auto;
margin-bottom: auto;
}
.post-user {
font-weight: bold;
margin-right: 10px;
color: var(--primary-color);
}
.post-date, .post-persona {
color: var(--secondary-color);
font-size: 0.8em;
margin-top: 0.3em;
}
.post-persona {
margin-right: 10px;
}
.post-content {
line-height: 1.4;
}
details {
margin-top: 10px;
padding: 20px;
background-color: var(--details-background-color);
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s ease;
}
details:hover {
background-color: var(--details-hover-color);
}
summary {
font-weight: bold;
color: var(--primary-color);
outline: none;
cursor: pointer;
}
details p {
margin-top: 10px;
color: #555;
}
details[open] summary {
color: #0056b3;
}
a {
text-decoration: none;
color: var(--primary-color);
transition: color 0.3s ease;
}
a:hover {
color: #0056b3;
}
ol, ul {
margin: 0.1em 0;
padding-left: 1em;
}
li {
margin-bottom: 0.5em;
line-height: 1.6;
}
li:last-child {
margin-bottom: 0;
}
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
p + ol, p + ul {
margin-top: -0.5em;
}
ol + p, ul + p {
margin-top: 1.5em;
}
li, p {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
@media screen and (max-width: 768px) {
body {
padding: 10px;
}
.header, details {
padding: 0.5rem;
}
.post-content {
font-size: 17px;
padding: 0;
}
.container {
padding: 0rem;
}
h1, h2 {
font-size: 1.5em;
line-height: 1.3;
}
.post-content, p, ol, ul {
line-height: 1.5;
}
.post-date {
display: none;
}
.site-title, .llm-name, .post-header {
font-size: 1.4em;
padding: 0;
margin: 0;
}
.llm-name {
padding: 0;
margin: 0;
display: inline-block;
font-size: 1em;
background-color: inherit;
border-radius: 0;
box-shadow: none;
color: black;
font-size: 15px;
}
.ai-logo {
display: block;
margin-left: -10px;
}
.site-title {
display: inline-block;
margin-right: 10px;
}
.post {
margin-bottom: 1rem;
padding: 5px;
background-color: var(--post-background-color);
border-radius: unset;
box-shadow: unset;
}
.post p {
margin-block-start: 0.4em;
}
.post-user, .post-persona {
font-size: 0.7em;
margin-top: 0;
}
.header {
display: block;
padding: 0;
}
body {
background-color: var(--post-background-color);
}
}

View File

@ -10,6 +10,14 @@ RSpec.describe DiscourseAi::AiBot::Tools::GithubPullRequestDiff do
context "with a valid pull request" do
let(:repo) { "discourse/discourse-automation" }
let(:pull_id) { 253 }
let(:diff) { <<~DIFF }
diff --git a/lib/discourse_automation/automation.rb b/lib/discourse_automation/automation.rb
index 3e3e3e3..4f4f4f4 100644
--- a/lib/discourse_automation/automation.rb
+++ b/lib/discourse_automation/automation.rb
@@ -1,3 +1,3 @@
-module DiscourseAutomation
DIFF
it "retrieves the diff for the pull request" do
stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with(
@ -17,10 +25,10 @@ RSpec.describe DiscourseAi::AiBot::Tools::GithubPullRequestDiff do
"Accept" => "application/vnd.github.v3.diff",
"User-Agent" => DiscourseAi::AiBot::USER_AGENT,
},
).to_return(status: 200, body: "sample diff")
).to_return(status: 200, body: diff)
result = tool.invoke(bot_user, llm)
expect(result[:diff]).to eq("sample diff")
expect(result[:diff]).to eq(diff)
expect(result[:error]).to be_nil
end
@ -33,10 +41,10 @@ RSpec.describe DiscourseAi::AiBot::Tools::GithubPullRequestDiff do
"User-Agent" => DiscourseAi::AiBot::USER_AGENT,
"Authorization" => "Bearer ABC",
},
).to_return(status: 200, body: "sample diff")
).to_return(status: 200, body: diff)
result = tool.invoke(bot_user, llm)
expect(result[:diff]).to eq("sample diff")
expect(result[:diff]).to eq(diff)
expect(result[:error]).to be_nil
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe SharedAiConversation, type: :model do
before do
SiteSetting.discourse_ai_enabled = true
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
SiteSetting.ai_bot_enabled = true
end
fab!(:user)
let(:raw_with_details) { <<~HTML }
<details>
<summary>GitHub pull request diff</summary>
<p><a href="https://github.com/discourse/discourse-ai/pull/521">discourse/discourse-ai 521</a></p>
</details>
<p>This is some other text</p>
HTML
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) }
let!(:topic) { Fabricate(:private_message_topic, recipient: bot_user) }
let!(:post1) { Fabricate(:post, topic: topic, post_number: 1) }
let!(:post2) { Fabricate(:post, topic: topic, post_number: 2, raw: raw_with_details) }
describe ".share_conversation" do
it "creates a new conversation if one does not exist" do
expect { described_class.share_conversation(user, topic) }.to change {
described_class.count
}.by(1)
end
it "generates a good onebox" do
conversation = described_class.share_conversation(user, topic)
onebox = conversation.onebox
expect(onebox).not_to include("GitHub pull request diff")
expect(onebox).not_to include("<details>")
expect(onebox).to include("AI Conversation with Claude-2")
end
it "updates an existing conversation if one exists" do
conversation = described_class.share_conversation(user, topic)
expect(conversation.share_key).to be_present
topic.update!(title: "New title")
expect { described_class.share_conversation(user, topic) }.to_not change {
described_class.count
}
expect(conversation.reload.title).to eq("New title")
expect(conversation.share_key).to be_present
end
it "includes the correct conversation data" do
conversation = described_class.share_conversation(user, topic)
expect(conversation.llm_name).to eq("Claude-2")
expect(conversation.title).to eq(topic.title)
expect(conversation.context.size).to eq(2)
expect(conversation.context[0]["id"]).to eq(post1.id)
expect(conversation.context[1]["id"]).to eq(post2.id)
populated_context = conversation.populated_context
expect(populated_context[0].id).to eq(post1.id)
expect(populated_context[0].user.id).to eq(post1.user.id)
expect(populated_context[1].id).to eq(post2.id)
expect(populated_context[1].user.id).to eq(post2.user.id)
end
end
end

View File

@ -0,0 +1,169 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe DiscourseAi::AiBot::SharedAiConversationsController do
before do
SiteSetting.discourse_ai_enabled = true
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
SiteSetting.ai_bot_enabled = true
SiteSetting.ai_bot_allowed_groups = "10"
SiteSetting.ai_bot_public_sharing_allowed_groups = "10"
end
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:topic)
fab!(:pm) { Fabricate(:private_message_topic) }
fab!(:user_pm) { Fabricate(:private_message_topic, recipient: user) }
fab!(:bot_user) do
SiteSetting.discourse_ai_enabled = true
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
SiteSetting.ai_bot_enabled = true
SiteSetting.ai_bot_allowed_groups = "10"
SiteSetting.ai_bot_public_sharing_allowed_groups = "10"
User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
end
fab!(:user_pm_share) do
pm_topic = Fabricate(:private_message_topic, user: user, recipient: bot_user)
# a different unknown user
Fabricate(:post, topic: pm_topic, user: user)
Fabricate(:post, topic: pm_topic, user: bot_user)
Fabricate(:post, topic: pm_topic, user: user)
pm_topic
end
let(:path) { "/discourse-ai/ai-bot/shared-ai-conversations" }
let(:shared_conversation) { SharedAiConversation.share_conversation(user, user_pm_share) }
def share_error(key)
I18n.t("discourse_ai.share_ai.errors.#{key}")
end
describe "POST create" do
context "when logged in" do
before { sign_in(user) }
it "denies creating a new shared conversation on public topics" do
post "#{path}.json", params: { topic_id: topic.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:not_allowed)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "denies creating a new shared conversation for a random PM" do
post "#{path}.json", params: { topic_id: pm.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:not_allowed)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "denies creating a shared conversation for my PMs not with bots" do
post "#{path}.json", params: { topic_id: user_pm.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:other_people_in_pm)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "denies creating a shared conversation for my PMs with bots that also have other users" do
pm_topic = Fabricate(:private_message_topic, user: user, recipient: bot_user)
# a different unknown user
Fabricate(:post, topic: pm_topic)
post "#{path}.json", params: { topic_id: pm_topic.id }
expect(response).not_to have_http_status(:success)
expect(response.parsed_body["errors"]).to eq([share_error(:other_content_in_pm)])
expect(response.parsed_body["errors"].to_s).not_to include("Translation missing")
end
it "allows creating a shared conversation for my PMs with bots only" do
post "#{path}.json", params: { topic_id: user_pm_share.id }
expect(response).to have_http_status(:success)
end
end
context "when not logged in" do
it "requires login" do
post "#{path}.json", params: { topic_id: topic.id }
expect(response).not_to have_http_status(:success)
end
end
end
describe "DELETE destroy" do
context "when logged in" do
before { sign_in(user) }
it "deletes the shared conversation" do
delete "#{path}/#{shared_conversation.share_key}.json"
expect(response).to have_http_status(:success)
expect(SharedAiConversation.exists?(shared_conversation.id)).to be_falsey
end
it "returns an error if the shared conversation is not found" do
delete "#{path}/123.json"
expect(response).not_to have_http_status(:success)
end
end
context "when not logged in" do
it "requires login" do
delete "#{path}/#{shared_conversation.share_key}.json"
expect(response).not_to have_http_status(:success)
end
end
end
describe "GET preview" do
it "denies preview from logged out users" do
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).not_to have_http_status(:success)
end
context "when logged in" do
before { sign_in(user) }
it "renders the shared conversation" do
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).to have_http_status(:success)
expect(response.parsed_body["llm_name"]).to eq("Claude-2")
expect(response.parsed_body["error"]).to eq(nil)
expect(response.parsed_body["share_key"]).to eq(nil)
expect(response.parsed_body["context"].length).to eq(3)
shared_conversation
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).to have_http_status(:success)
expect(response.parsed_body["share_key"]).to eq(shared_conversation.share_key)
SiteSetting.ai_bot_public_sharing_allowed_groups = ""
get "#{path}/preview/#{user_pm_share.id}.json"
expect(response).not_to have_http_status(:success)
end
end
end
describe "GET show" do
it "renders the shared conversation" do
get "#{path}/#{shared_conversation.share_key}"
expect(response).to have_http_status(:success)
expect(response.headers["Cache-Control"]).to eq("max-age=60, public")
expect(response.headers["X-Robots-Tag"]).to eq("noindex")
end
it "is also able to render in json format" do
get "#{path}/#{shared_conversation.share_key}.json"
expect(response.parsed_body["llm_name"]).to eq("Claude-2")
expect(response.headers["X-Robots-Tag"]).to eq("noindex")
end
it "returns an error if the shared conversation is not found" do
get "#{path}/123"
expect(response).to have_http_status(:not_found)
end
end
end