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
|
topic = post.topic
|
||||||
reply_to = post.reply_to_post
|
reply_to = post.reply_to_post
|
||||||
|
|
||||||
guardian = Guardian.new(user)
|
return unless user.guardian.can_see?(post)
|
||||||
return unless guardian.can_see?(post)
|
|
||||||
|
|
||||||
prompt = CompletionPrompt.enabled_by_name("explain")
|
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 { Input } from "@ember/component";
|
||||||
import { on } from "@ember/modifier";
|
import { on } from "@ember/modifier";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
import { htmlSafe } from "@ember/template";
|
import { htmlSafe } from "@ember/template";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import DModal from "discourse/components/d-modal";
|
import DModal from "discourse/components/d-modal";
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
import discourseLater from "discourse-common/lib/later";
|
import discourseLater from "discourse-common/lib/later";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
import { showShareConversationModal } from "../../lib/ai-bot-helper";
|
||||||
import copyConversation from "../../lib/copy-conversation";
|
import copyConversation from "../../lib/copy-conversation";
|
||||||
|
|
||||||
export default class ShareModal extends Component {
|
export default class ShareModal extends Component {
|
||||||
|
@service modal;
|
||||||
|
@service siteSettings;
|
||||||
|
@service currentUser;
|
||||||
@tracked contextValue = 1;
|
@tracked contextValue = 1;
|
||||||
@tracked htmlContext = "";
|
@tracked htmlContext = "";
|
||||||
@tracked maxContext = 0;
|
@tracked maxContext = 0;
|
||||||
|
@ -71,6 +76,14 @@ export default class ShareModal extends Component {
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
shareConversationModal(event) {
|
||||||
|
event?.preventDefault();
|
||||||
|
this.args.closeModal();
|
||||||
|
showShareConversationModal(this.modal, this.args.model.topic_id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DModal
|
<DModal
|
||||||
class="ai-share-modal"
|
class="ai-share-modal"
|
||||||
|
@ -104,6 +117,13 @@ export default class ShareModal extends Component {
|
||||||
@label="discourse_ai.ai_bot.share_modal.copy"
|
@label="discourse_ai.ai_bot.share_modal.copy"
|
||||||
/>
|
/>
|
||||||
<span class="ai-share-modal__just-copied">{{this.justCopiedText}}</span>
|
<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>
|
</:footer>
|
||||||
</DModal>
|
</DModal>
|
||||||
</template>
|
</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 Composer from "discourse/models/composer";
|
||||||
import I18n from "I18n";
|
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) {
|
export function composeAiBotMessage(targetBot, composer) {
|
||||||
const currentUser = composer.currentUser;
|
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";
|
import copyConversation from "../discourse/lib/copy-conversation";
|
||||||
const AUTO_COPY_THRESHOLD = 4;
|
const AUTO_COPY_THRESHOLD = 4;
|
||||||
import AiBotHeaderIcon from "../discourse/components/ai-bot-header-icon";
|
import AiBotHeaderIcon from "../discourse/components/ai-bot-header-icon";
|
||||||
|
import { showShareConversationModal } from "../discourse/lib/ai-bot-helper";
|
||||||
|
|
||||||
let enabledChatBotIds = [];
|
let enabledChatBotIds = [];
|
||||||
function isGPTBot(user) {
|
function isGPTBot(user) {
|
||||||
|
@ -145,6 +146,29 @@ function initializeShareButton(api) {
|
||||||
const modal = api.container.lookup("service:modal");
|
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 {
|
export default {
|
||||||
name: "discourse-ai-bot-replies",
|
name: "discourse-ai-bot-replies",
|
||||||
|
|
||||||
|
@ -157,6 +181,9 @@ export default {
|
||||||
withPluginApi("1.6.0", initializeAIBotReplies);
|
withPluginApi("1.6.0", initializeAIBotReplies);
|
||||||
withPluginApi("1.6.0", initializePersonaDecorator);
|
withPluginApi("1.6.0", initializePersonaDecorator);
|
||||||
withPluginApi("1.22.0", (api) => initializeShareButton(api, container));
|
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);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.onebox-ai-llm-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
|
@ -222,13 +222,24 @@ en:
|
||||||
share: "Share AI conversation"
|
share: "Share AI conversation"
|
||||||
conversation_shared: "Conversation copied"
|
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_label: "AI"
|
||||||
ai_title: "Conversation with AI"
|
ai_title: "Conversation with AI"
|
||||||
|
|
||||||
share_modal:
|
share_modal:
|
||||||
title: "Share AI conversation"
|
title: "Copy AI conversation"
|
||||||
copy: "Copy"
|
copy: "Copy"
|
||||||
context: "Interactions to share:"
|
context: "Interactions to share:"
|
||||||
|
share_tip: Alternatively, you can share the entire conversation.
|
||||||
|
|
||||||
bot_names:
|
bot_names:
|
||||||
fake: "Fake Test Bot"
|
fake: "Fake Test Bot"
|
||||||
|
|
|
@ -95,6 +95,7 @@ en:
|
||||||
ai_bot_enabled: "Enable the AI Bot module."
|
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_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_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_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_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)"
|
ai_bot_github_access_token: "GitHub access token for use with GitHub AI tools (required for search support)"
|
||||||
|
@ -134,6 +135,8 @@ en:
|
||||||
yaxis:
|
yaxis:
|
||||||
|
|
||||||
discourse_ai:
|
discourse_ai:
|
||||||
|
unknown_model: "Unknown AI model"
|
||||||
|
|
||||||
ai_helper:
|
ai_helper:
|
||||||
errors:
|
errors:
|
||||||
completion_request_failed: "Something went wrong while trying to provide suggestions. Please, try again."
|
completion_request_failed: "Something went wrong while trying to provide suggestions. Please, try again."
|
||||||
|
@ -152,6 +155,16 @@ en:
|
||||||
image_caption:
|
image_caption:
|
||||||
attribution: "Captioned by AI"
|
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:
|
ai_bot:
|
||||||
personas:
|
personas:
|
||||||
cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead"
|
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"
|
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
|
||||||
get "bot-username" => "bot#show_bot_username"
|
get "bot-username" => "bot#show_bot_username"
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
Discourse::Application.routes.draw do
|
Discourse::Application.routes.draw do
|
||||||
|
|
|
@ -311,6 +311,13 @@ discourse_ai:
|
||||||
list_type: compact
|
list_type: compact
|
||||||
default: "3|14" # 3: @staff, 14: @trust_level_4
|
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.
|
# 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:
|
ai_bot_enabled_chat_bots:
|
||||||
type: list
|
type: list
|
||||||
default: "gpt-3.5-turbo"
|
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_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)
|
def self.map_bot_model_to_user_id(model_name)
|
||||||
case model_name
|
case model_name
|
||||||
in "gpt-4-turbo"
|
in "gpt-4-turbo"
|
||||||
|
@ -56,6 +64,28 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
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)
|
def inject_into(plugin)
|
||||||
plugin.on(:site_setting_changed) do |name, _old_value, _new_value|
|
plugin.on(:site_setting_changed) do |name, _old_value, _new_value|
|
||||||
if name == :ai_bot_enabled_chat_bots || name == :ai_bot_enabled ||
|
if name == :ai_bot_enabled_chat_bots || name == :ai_bot_enabled ||
|
||||||
|
@ -64,6 +94,20 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
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(
|
plugin.register_seedfu_fixtures(
|
||||||
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
|
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)
|
scope.user.in_any_groups?(SiteSetting.ai_helper_custom_prompts_allowed_groups_map)
|
||||||
end
|
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.register_svg_icon("robot")
|
||||||
|
|
||||||
plugin.add_to_serializer(
|
plugin.add_to_serializer(
|
||||||
|
|
|
@ -314,7 +314,7 @@ module DiscourseAi
|
||||||
post.topic.save_custom_fields
|
post.topic.save_custom_fields
|
||||||
|
|
||||||
::Jobs.enqueue_in(
|
::Jobs.enqueue_in(
|
||||||
5.minutes,
|
1.minute,
|
||||||
:update_ai_bot_pm_title,
|
:update_ai_bot_pm_title,
|
||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
bot_user_id: bot.bot_user.id,
|
bot_user_id: bot.bot_user.id,
|
||||||
|
|
|
@ -4,6 +4,8 @@ module DiscourseAi
|
||||||
module AiBot
|
module AiBot
|
||||||
module Tools
|
module Tools
|
||||||
class GithubPullRequestDiff < Tool
|
class GithubPullRequestDiff < Tool
|
||||||
|
LARGE_OBJECT_THRESHOLD = 30_000
|
||||||
|
|
||||||
def self.signature
|
def self.signature
|
||||||
{
|
{
|
||||||
name: name,
|
name: name,
|
||||||
|
@ -56,6 +58,7 @@ module DiscourseAi
|
||||||
|
|
||||||
if response.code == "200"
|
if response.code == "200"
|
||||||
diff = response.body
|
diff = response.body
|
||||||
|
diff = sort_and_shorten_diff(diff)
|
||||||
diff = truncate(diff, max_length: 20_000, percent_length: 0.3, llm: llm)
|
diff = truncate(diff, max_length: 20_000, percent_length: 0.3, llm: llm)
|
||||||
{ diff: diff }
|
{ diff: diff }
|
||||||
else
|
else
|
||||||
|
@ -66,6 +69,46 @@ module DiscourseAi
|
||||||
def description_args
|
def description_args
|
||||||
{ repo: repo, pull_id: pull_id, url: url }
|
{ repo: repo, pull_id: pull_id, url: url }
|
||||||
end
|
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
|
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/embeddings_generation_stubs"
|
||||||
require_relative "spec/support/stable_diffusion_stubs"
|
require_relative "spec/support/stable_diffusion_stubs"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
reloadable_patch { |plugin| Guardian.prepend DiscourseAi::GuardianExtensions }
|
||||||
end
|
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
|
context "with a valid pull request" do
|
||||||
let(:repo) { "discourse/discourse-automation" }
|
let(:repo) { "discourse/discourse-automation" }
|
||||||
let(:pull_id) { 253 }
|
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
|
it "retrieves the diff for the pull request" do
|
||||||
stub_request(:get, "https://api.github.com/repos/#{repo}/pulls/#{pull_id}").with(
|
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",
|
"Accept" => "application/vnd.github.v3.diff",
|
||||||
"User-Agent" => DiscourseAi::AiBot::USER_AGENT,
|
"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)
|
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
|
expect(result[:error]).to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -33,10 +41,10 @@ RSpec.describe DiscourseAi::AiBot::Tools::GithubPullRequestDiff do
|
||||||
"User-Agent" => DiscourseAi::AiBot::USER_AGENT,
|
"User-Agent" => DiscourseAi::AiBot::USER_AGENT,
|
||||||
"Authorization" => "Bearer ABC",
|
"Authorization" => "Bearer ABC",
|
||||||
},
|
},
|
||||||
).to_return(status: 200, body: "sample diff")
|
).to_return(status: 200, body: diff)
|
||||||
|
|
||||||
result = tool.invoke(bot_user, llm)
|
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
|
expect(result[:error]).to be_nil
|
||||||
end
|
end
|
||||||
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