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:
parent
740731ab53
commit
a03bc6ddec
|
@ -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
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
#
|
|
@ -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>
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export default function () {
|
||||
this.route("discourse-ai-shared-conversation-show", {
|
||||
path: "/discourse-ai/ai-bot/shared-ai-conversations/:share_key",
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
beforeModel(transition) {
|
||||
window.location = transition.intent.url;
|
||||
transition.abort();
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
<p></p>
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -137,3 +137,7 @@ details.ai-quote {
|
|||
color: var(--success);
|
||||
}
|
||||
}
|
||||
|
||||
span.onebox-ai-llm-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 |
|
@ -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}
|
File diff suppressed because one or more lines are too long
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue