Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d94234d89f | ||
|
|
a65c4076a6 |
@@ -1,4 +1,3 @@
|
||||
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
|
||||
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
|
||||
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
||||
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
|
||||
|
||||
+7
-31
@@ -1,30 +1,11 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
activesupport (7.1.3.2)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
tzinfo (~> 2.0)
|
||||
ast (2.4.2)
|
||||
base64 (0.2.0)
|
||||
bigdecimal (3.1.6)
|
||||
concurrent-ruby (1.2.3)
|
||||
connection_pool (2.4.1)
|
||||
drb (2.2.1)
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.7.1)
|
||||
language_server-protocol (3.17.0.3)
|
||||
minitest (5.22.2)
|
||||
mutex_m (0.2.0)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.0.5)
|
||||
parser (3.3.0.3)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
prettier_print (1.2.1)
|
||||
@@ -32,38 +13,33 @@ GEM
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.9.0)
|
||||
rexml (3.2.6)
|
||||
rubocop (1.61.0)
|
||||
rubocop (1.59.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
parser (>= 3.2.2.4)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.31.1)
|
||||
parser (>= 3.3.0.4)
|
||||
rubocop-ast (1.30.0)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-capybara (2.20.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-discourse (3.7.1)
|
||||
activesupport (>= 6.1)
|
||||
rubocop-discourse (3.6.0)
|
||||
rubocop (>= 1.59.0)
|
||||
rubocop-capybara (>= 2.0.0)
|
||||
rubocop-factory_bot (>= 2.0.0)
|
||||
rubocop-rspec (>= 2.25.0)
|
||||
rubocop-factory_bot (2.25.1)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-rspec (2.27.1)
|
||||
rubocop-rspec (2.26.1)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
ruby-progressbar (1.13.0)
|
||||
syntax_tree (6.2.0)
|
||||
prettier_print (>= 1.2.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
|
||||
PLATFORMS
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { AUTO_GROUPS } from "discourse/lib/constants";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model() {
|
||||
const record = this.store.createRecord("ai-persona");
|
||||
record.set("allowed_group_ids", [AUTO_GROUPS.trust_level_0.id]);
|
||||
record.set("rag_uploads", []);
|
||||
// these match the defaults on the table
|
||||
record.set("rag_chunk_tokens", 374);
|
||||
record.set("rag_chunk_overlap_tokens", 10);
|
||||
record.set("rag_conversation_chunks", 10);
|
||||
return record;
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
this._super(controller, model);
|
||||
controller.set(
|
||||
"allPersonas",
|
||||
this.modelFor("adminPlugins.show.discourse-ai-personas")
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -3,10 +3,7 @@
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
before_action :find_ai_persona,
|
||||
only: %i[show update destroy create_user indexing_status_check]
|
||||
before_action :find_ai_persona, only: %i[show update destroy create_user]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
@@ -31,10 +28,8 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def create
|
||||
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
|
||||
ai_persona = AiPersona.new(ai_persona_params)
|
||||
if ai_persona.save
|
||||
RagDocumentFragment.link_persona_and_uploads(ai_persona, attached_upload_ids)
|
||||
|
||||
render json: { ai_persona: ai_persona }, status: :created
|
||||
else
|
||||
render_json_error ai_persona
|
||||
@@ -47,9 +42,7 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def update
|
||||
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
|
||||
RagDocumentFragment.update_persona_uploads(@ai_persona, attached_upload_ids)
|
||||
|
||||
if @ai_persona.update(ai_persona_params)
|
||||
render json: @ai_persona
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
@@ -64,47 +57,12 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
|
||||
def upload_file
|
||||
file = params[:file] || params[:files].first
|
||||
|
||||
if !SiteSetting.ai_embeddings_enabled?
|
||||
raise Discourse::InvalidAccess.new("Embeddings not enabled")
|
||||
end
|
||||
|
||||
validate_extension!(file.original_filename)
|
||||
validate_file_size!(file.tempfile.size)
|
||||
|
||||
hijack do
|
||||
upload =
|
||||
UploadCreator.new(
|
||||
file.tempfile,
|
||||
file.original_filename,
|
||||
type: "discourse_ai_rag_upload",
|
||||
skip_validations: true,
|
||||
).create_for(current_user.id)
|
||||
|
||||
if upload.persisted?
|
||||
render json: UploadSerializer.new(upload)
|
||||
else
|
||||
render json: failed_json.merge(errors: upload.errors.full_messages), status: 422
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def indexing_status_check
|
||||
render json: RagDocumentFragment.indexing_status(@ai_persona, @ai_persona.uploads)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_ai_persona
|
||||
@ai_persona = AiPersona.find(params[:id])
|
||||
end
|
||||
|
||||
def attached_upload_ids
|
||||
ai_persona_params[:rag_uploads].to_a.map { |h| h[:id] }
|
||||
end
|
||||
|
||||
def ai_persona_params
|
||||
permitted =
|
||||
params.require(:ai_persona).permit(
|
||||
@@ -119,14 +77,7 @@ module DiscourseAi
|
||||
:user_id,
|
||||
:mentionable,
|
||||
:max_context_posts,
|
||||
:vision_enabled,
|
||||
:vision_max_pixels,
|
||||
:rag_chunk_tokens,
|
||||
:rag_chunk_overlap_tokens,
|
||||
:rag_conversation_chunks,
|
||||
:question_consolidator_llm,
|
||||
allowed_group_ids: [],
|
||||
rag_uploads: [:id],
|
||||
)
|
||||
|
||||
if commands = params.dig(:ai_persona, :commands)
|
||||
@@ -150,31 +101,6 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_extension!(filename)
|
||||
extension = File.extname(filename)[1..-1] || ""
|
||||
authorized_extensions = %w[txt md]
|
||||
if !authorized_extensions.include?(extension)
|
||||
raise Discourse::InvalidParameters.new(
|
||||
I18n.t(
|
||||
"upload.unauthorized",
|
||||
authorized_extensions: authorized_extensions.join(" "),
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_file_size!(filesize)
|
||||
max_size_bytes = 20.megabytes
|
||||
if filesize > max_size_bytes
|
||||
raise Discourse::InvalidParameters.new(
|
||||
I18n.t(
|
||||
"upload.attachments.too_large_humanized",
|
||||
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes),
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,21 +6,6 @@ module DiscourseAi
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
|
||||
def show_debug_info
|
||||
post = Post.find(params[:post_id])
|
||||
guardian.ensure_can_debug_ai_bot_conversation!(post)
|
||||
|
||||
posts =
|
||||
Post
|
||||
.where("post_number <= ?", post.post_number)
|
||||
.where(topic_id: post.topic_id)
|
||||
.order("post_number DESC")
|
||||
|
||||
debug_info = AiApiAuditLog.where(post: posts).order(created_at: :desc).first
|
||||
|
||||
render json: debug_info, status: 200
|
||||
end
|
||||
|
||||
def stop_streaming_response
|
||||
post = Post.find(params[:post_id])
|
||||
guardian.ensure_can_see!(post)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# 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!
|
||||
|
||||
SharedAiConversation.destroy_conversation(@shared_conversation)
|
||||
|
||||
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
|
||||
@@ -8,8 +8,6 @@ module DiscourseAi
|
||||
before_action :ensure_can_request_suggestions
|
||||
before_action :rate_limiter_performed!, except: %i[prompts]
|
||||
|
||||
include SecureUploadEndpointHelpers
|
||||
|
||||
def suggest
|
||||
input = get_text_param!
|
||||
|
||||
@@ -107,31 +105,6 @@ module DiscourseAi
|
||||
status: 502
|
||||
end
|
||||
|
||||
def caption_image
|
||||
image_url = params[:image_url]
|
||||
raise Discourse::InvalidParameters.new(:image_url) if !image_url
|
||||
|
||||
image = upload_from_full_url(image_url)
|
||||
raise Discourse::NotFound if image.blank?
|
||||
final_image_url = get_caption_url(image, image_url)
|
||||
|
||||
hijack do
|
||||
caption =
|
||||
DiscourseAi::AiHelper::Assistant.new.generate_image_caption(
|
||||
final_image_url,
|
||||
current_user,
|
||||
)
|
||||
render json: {
|
||||
caption:
|
||||
"#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})",
|
||||
},
|
||||
status: 200
|
||||
end
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed, Net::HTTPBadResponse
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_text_param!
|
||||
@@ -147,18 +120,14 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def ensure_can_request_suggestions
|
||||
if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
|
||||
raise Discourse::InvalidAccess
|
||||
end
|
||||
end
|
||||
user_group_ids = current_user.group_ids
|
||||
|
||||
def get_caption_url(image_upload, image_url)
|
||||
if image_upload.secure?
|
||||
check_secure_upload_permission(image_upload)
|
||||
return Discourse.store.url_for(image_upload)
|
||||
end
|
||||
allowed =
|
||||
SiteSetting.ai_helper_allowed_groups_map.any? do |group_id|
|
||||
user_group_ids.include?(group_id)
|
||||
end
|
||||
|
||||
UrlHelper.absolute(image_url)
|
||||
raise Discourse::InvalidAccess if !allowed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,34 +36,6 @@ module DiscourseAi
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
end
|
||||
end
|
||||
|
||||
def quick_search
|
||||
query = params[:q].to_s
|
||||
|
||||
if query.length < SiteSetting.min_search_term_length
|
||||
raise Discourse::InvalidParameters.new(:q)
|
||||
end
|
||||
|
||||
grouped_results =
|
||||
Search::GroupedSearchResults.new(
|
||||
type_filter: SEMANTIC_SEARCH_TYPE,
|
||||
term: query,
|
||||
search_context: guardian,
|
||||
use_pg_headlines_for_excerpt: false,
|
||||
)
|
||||
|
||||
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
|
||||
|
||||
if !semantic_search.cached_query?(query)
|
||||
RateLimiter.new(current_user, "semantic-search", 60, 1.minutes).performed!
|
||||
end
|
||||
|
||||
hijack do
|
||||
semantic_search.quick_search(query).each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
module DiscourseAi
|
||||
module AiBot
|
||||
module SharedAiConversationsHelper
|
||||
# bump up version when assets change
|
||||
# long term we may want to change this cause it is hard to remember
|
||||
# to bump versions, but for now this does the job
|
||||
VERSION = "1"
|
||||
|
||||
def share_asset_url(short_path)
|
||||
::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,172 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class DigestRagUpload < ::Jobs::Base
|
||||
CHUNK_SIZE = 1024
|
||||
CHUNK_OVERLAP = 64
|
||||
MAX_FRAGMENTS = 100_000
|
||||
|
||||
# TODO(roman): Add a way to automatically recover from errors, resulting in unindexed uploads.
|
||||
def execute(args)
|
||||
return if (upload = Upload.find_by(id: args[:upload_id])).nil?
|
||||
return if (ai_persona = AiPersona.find_by(id: args[:ai_persona_id])).nil?
|
||||
|
||||
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
|
||||
vector_rep =
|
||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
||||
|
||||
tokenizer = vector_rep.tokenizer
|
||||
chunk_tokens = ai_persona.rag_chunk_tokens
|
||||
overlap_tokens = ai_persona.rag_chunk_overlap_tokens
|
||||
|
||||
fragment_ids = RagDocumentFragment.where(ai_persona: ai_persona, upload: upload).pluck(:id)
|
||||
|
||||
# Check if this is the first time we process this upload.
|
||||
if fragment_ids.empty?
|
||||
document = get_uploaded_file(upload)
|
||||
return if document.nil?
|
||||
|
||||
RagDocumentFragment.publish_status(upload, { total: 0, indexed: 0, left: 0 })
|
||||
|
||||
fragment_ids = []
|
||||
idx = 0
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
chunk_document(
|
||||
file: document,
|
||||
tokenizer: tokenizer,
|
||||
chunk_tokens: chunk_tokens,
|
||||
overlap_tokens: overlap_tokens,
|
||||
) do |chunk, metadata|
|
||||
fragment_ids << RagDocumentFragment.create!(
|
||||
ai_persona: ai_persona,
|
||||
fragment: chunk,
|
||||
fragment_number: idx + 1,
|
||||
upload: upload,
|
||||
metadata: metadata,
|
||||
).id
|
||||
|
||||
idx += 1
|
||||
|
||||
if idx > MAX_FRAGMENTS
|
||||
Rails.logger.warn("Upload #{upload.id} has too many fragments, truncating.")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
fragment_ids.each_slice(50) do |slice|
|
||||
Jobs.enqueue(:generate_rag_embeddings, fragment_ids: slice)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def chunk_document(file:, tokenizer:, chunk_tokens:, overlap_tokens:)
|
||||
buffer = +""
|
||||
current_metadata = nil
|
||||
done = false
|
||||
overlap = ""
|
||||
|
||||
# generally this will be plenty
|
||||
read_size = chunk_tokens * 10
|
||||
|
||||
while buffer.present? || !done
|
||||
if buffer.length < read_size
|
||||
read = file.read(read_size)
|
||||
done = true if read.nil?
|
||||
|
||||
read = Encodings.to_utf8(read) if read
|
||||
|
||||
buffer << (read || "")
|
||||
end
|
||||
|
||||
# at this point we unconditionally have 2x CHUNK_SIZE worth of data in the buffer
|
||||
metadata_regex = /\[\[metadata (.*?)\]\]/m
|
||||
|
||||
before_metadata, new_metadata, after_metadata = buffer.split(metadata_regex)
|
||||
to_chunk = nil
|
||||
|
||||
if before_metadata.present?
|
||||
to_chunk = before_metadata
|
||||
elsif after_metadata.present?
|
||||
current_metadata = new_metadata
|
||||
to_chunk = after_metadata
|
||||
buffer = buffer.split(metadata_regex, 2).last
|
||||
overlap = ""
|
||||
else
|
||||
current_metadata = new_metadata
|
||||
buffer = buffer.split(metadata_regex, 2).last
|
||||
overlap = ""
|
||||
next
|
||||
end
|
||||
|
||||
chunk, split_char = first_chunk(to_chunk, tokenizer: tokenizer, chunk_tokens: chunk_tokens)
|
||||
buffer = buffer[chunk.length..-1]
|
||||
|
||||
processed_chunk = overlap + chunk
|
||||
|
||||
processed_chunk.strip!
|
||||
processed_chunk.gsub!(/\n[\n]+/, "\n\n")
|
||||
|
||||
yield processed_chunk, current_metadata
|
||||
|
||||
current_chunk_tokens = tokenizer.encode(chunk)
|
||||
overlap_token_ids = current_chunk_tokens[-overlap_tokens..-1] || current_chunk_tokens
|
||||
|
||||
overlap = ""
|
||||
|
||||
while overlap_token_ids.present?
|
||||
begin
|
||||
overlap = tokenizer.decode(overlap_token_ids) + split_char
|
||||
break if overlap.encoding == Encoding::UTF_8
|
||||
rescue StandardError
|
||||
# it is possible that we truncated mid char
|
||||
end
|
||||
overlap_token_ids.shift
|
||||
end
|
||||
|
||||
# remove first word it is probably truncated
|
||||
overlap = overlap.split(" ", 2).last
|
||||
end
|
||||
end
|
||||
|
||||
def first_chunk(text, chunk_tokens:, tokenizer:, splitters: ["\n\n", "\n", ".", ""])
|
||||
return text, " " if tokenizer.tokenize(text).length <= chunk_tokens
|
||||
|
||||
splitters = splitters.find_all { |s| text.include?(s) }.compact
|
||||
|
||||
buffer = +""
|
||||
split_char = nil
|
||||
|
||||
splitters.each do |splitter|
|
||||
split_char = splitter
|
||||
|
||||
text
|
||||
.split(split_char)
|
||||
.each do |part|
|
||||
break if tokenizer.tokenize(buffer + split_char + part).length > chunk_tokens
|
||||
buffer << split_char
|
||||
buffer << part
|
||||
end
|
||||
break if buffer.length > 0
|
||||
end
|
||||
|
||||
[buffer, split_char]
|
||||
end
|
||||
|
||||
def get_uploaded_file(upload)
|
||||
store = Discourse.store
|
||||
@file ||=
|
||||
if store.external?
|
||||
# Upload#filesize could be approximate.
|
||||
# add two extra Mbs to make sure that we'll be able to download the upload.
|
||||
max_filesize = upload.filesize + 2.megabytes
|
||||
store.download(upload, max_file_size_kb: max_filesize)
|
||||
else
|
||||
File.open(store.path_for(upload))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,27 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class GenerateRagEmbeddings < ::Jobs::Base
|
||||
sidekiq_options queue: "ultra_low"
|
||||
# we could also restrict concurrency but this takes so long if it is not concurrent
|
||||
|
||||
def execute(args)
|
||||
return if (fragments = RagDocumentFragment.where(id: args[:fragment_ids].to_a)).empty?
|
||||
|
||||
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
|
||||
vector_rep =
|
||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
||||
|
||||
# generate_representation_from checks compares the digest value to make sure
|
||||
# the embedding is only generated once per fragment unless something changes.
|
||||
fragments.map { |fragment| vector_rep.generate_representation_from(fragment) }
|
||||
|
||||
last_fragment = fragments.last
|
||||
ai_persona = last_fragment.ai_persona
|
||||
upload = last_fragment.upload
|
||||
|
||||
indexing_status = RagDocumentFragment.indexing_status(ai_persona, [upload])[upload.id]
|
||||
RagDocumentFragment.publish_status(upload, indexing_status)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,51 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class SharedConversationAdjustUploadSecurity < ::Jobs::Base
|
||||
def execute(args)
|
||||
if args[:conversation_id].present?
|
||||
# The conversation context includes post cooked content so this
|
||||
# must be updated when target uploads security changes.
|
||||
update_conversation(args[:conversation_id])
|
||||
elsif args[:target_id].present? && args[:target_type].present?
|
||||
# If we deleted the conversation then we just need to update the target's
|
||||
# uploads security, no need to update the conversation.
|
||||
update_target(args[:target_id], args[:target_type])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_conversation(conversation_id)
|
||||
conversation = SharedAiConversation.find_by(id: conversation_id)
|
||||
return if conversation.blank?
|
||||
|
||||
# NOTE: Only Topics are supported for now, in future we will need a more flexible
|
||||
# way of doing this.
|
||||
if conversation.target_type == "Topic"
|
||||
rebaked_posts = TopicUploadSecurityManager.new(conversation.target).run
|
||||
|
||||
if rebaked_posts.any?
|
||||
new_context =
|
||||
conversation.context.map do |context_post|
|
||||
rebaked_post = rebaked_posts.find { |p| p.id == context_post["id"] }
|
||||
context_post["cooked"] = rebaked_post.cooked if rebaked_post
|
||||
context_post
|
||||
end
|
||||
|
||||
conversation.update(context: new_context)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_target(target_id, target_type)
|
||||
# NOTE: Only Topics are supported for now, in future we will need a more flexible
|
||||
# way of doing this.
|
||||
if target_type == "Topic"
|
||||
topic = target_type.constantize.find_by(id: target_id)
|
||||
return if topic.blank?
|
||||
TopicUploadSecurityManager.new(topic).run
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,8 @@ module Jobs
|
||||
topic = post.topic
|
||||
reply_to = post.reply_to_post
|
||||
|
||||
return unless user.guardian.can_see?(post)
|
||||
guardian = Guardian.new(user)
|
||||
return unless guardian.can_see?(post)
|
||||
|
||||
prompt = CompletionPrompt.enabled_by_name("explain")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ module ::Jobs
|
||||
|
||||
def execute(args)
|
||||
return unless bot_user = User.find_by(id: args[:bot_user_id])
|
||||
return unless bot = DiscourseAi::AiBot::Bot.as(bot_user, model: args[:model])
|
||||
return unless bot = DiscourseAi::AiBot::Bot.as(bot_user)
|
||||
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
|
||||
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiApiAuditLog < ActiveRecord::Base
|
||||
belongs_to :post
|
||||
belongs_to :topic
|
||||
|
||||
module Provider
|
||||
OpenAI = 1
|
||||
Anthropic = 2
|
||||
HuggingFaceTextGeneration = 3
|
||||
Gemini = 4
|
||||
Vllm = 5
|
||||
Cohere = 6
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+65
-140
@@ -9,30 +9,44 @@ class AiPersona < ActiveRecord::Base
|
||||
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
||||
validate :system_persona_unchangeable, on: :update, if: :system
|
||||
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
|
||||
# leaves some room for growth but sets a maximum to avoid memory issues
|
||||
# we may want to revisit this in the future
|
||||
validates :vision_max_pixels, numericality: { greater_than: 0, maximum: 4_000_000 }
|
||||
|
||||
validates :rag_chunk_tokens, numericality: { greater_than: 0, maximum: 50_000 }
|
||||
validates :rag_chunk_overlap_tokens, numericality: { greater_than: -1, maximum: 200 }
|
||||
validates :rag_conversation_chunks, numericality: { greater_than: 0, maximum: 1000 }
|
||||
|
||||
belongs_to :created_by, class_name: "User"
|
||||
belongs_to :user
|
||||
|
||||
has_many :upload_references, as: :target, dependent: :destroy
|
||||
has_many :uploads, through: :upload_references
|
||||
|
||||
has_many :rag_document_fragment, dependent: :destroy
|
||||
|
||||
has_many :rag_document_fragments, through: :ai_persona_rag_document_fragments
|
||||
|
||||
before_destroy :ensure_not_system
|
||||
|
||||
before_update :regenerate_rag_fragments
|
||||
class MultisiteHash
|
||||
def initialize(id)
|
||||
@hash = Hash.new { |h, k| h[k] = {} }
|
||||
@id = id
|
||||
|
||||
MessageBus.subscribe(channel_name) { |message| @hash[message.data] = {} }
|
||||
end
|
||||
|
||||
def channel_name
|
||||
"/multisite-hash-#{@id}"
|
||||
end
|
||||
|
||||
def current_db
|
||||
RailsMultisite::ConnectionManagement.current_db
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@hash.dig(current_db, key)
|
||||
end
|
||||
|
||||
def []=(key, val)
|
||||
@hash[current_db][key] = val
|
||||
end
|
||||
|
||||
def flush!
|
||||
@hash[current_db] = {}
|
||||
MessageBus.publish(channel_name, current_db)
|
||||
end
|
||||
end
|
||||
|
||||
def self.persona_cache
|
||||
@persona_cache ||= ::DiscourseAi::MultisiteHash.new("persona_cache")
|
||||
@persona_cache ||= MultisiteHash.new("persona_cache")
|
||||
end
|
||||
|
||||
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
|
||||
@@ -46,54 +60,21 @@ class AiPersona < ActiveRecord::Base
|
||||
.map(&:class_instance)
|
||||
end
|
||||
|
||||
def self.persona_users(user: nil)
|
||||
persona_users =
|
||||
persona_cache[:persona_users] ||= AiPersona
|
||||
.where(enabled: true)
|
||||
.joins(:user)
|
||||
.pluck(
|
||||
"ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm, mentionable",
|
||||
)
|
||||
.map do |id, user_id, username, allowed_group_ids, default_llm, mentionable|
|
||||
{
|
||||
id: id,
|
||||
user_id: user_id,
|
||||
username: username,
|
||||
allowed_group_ids: allowed_group_ids,
|
||||
default_llm: default_llm,
|
||||
mentionable: mentionable,
|
||||
}
|
||||
end
|
||||
|
||||
if user
|
||||
persona_users.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
|
||||
else
|
||||
persona_users
|
||||
end
|
||||
end
|
||||
|
||||
def self.mentionables(user: nil)
|
||||
all_mentionables =
|
||||
persona_cache[:mentionable_usernames] ||= AiPersona
|
||||
.where(mentionable: true)
|
||||
.where(enabled: true)
|
||||
.joins(:user)
|
||||
.pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm")
|
||||
.map do |id, user_id, username, allowed_group_ids, default_llm|
|
||||
{
|
||||
id: id,
|
||||
user_id: user_id,
|
||||
username: username,
|
||||
allowed_group_ids: allowed_group_ids,
|
||||
default_llm: default_llm,
|
||||
}
|
||||
end
|
||||
|
||||
if user
|
||||
all_mentionables.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
|
||||
else
|
||||
all_mentionables
|
||||
end
|
||||
def self.mentionables
|
||||
persona_cache[:mentionable_usernames] ||= AiPersona
|
||||
.where(mentionable: true)
|
||||
.where(enabled: true)
|
||||
.joins(:user)
|
||||
.pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm")
|
||||
.map do |id, user_id, username, allowed_group_ids, default_llm|
|
||||
{
|
||||
id: id,
|
||||
user_id: user_id,
|
||||
username: username,
|
||||
allowed_group_ids: allowed_group_ids,
|
||||
default_llm: default_llm,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
@@ -110,10 +91,6 @@ class AiPersona < ActiveRecord::Base
|
||||
mentionable = self.mentionable
|
||||
default_llm = self.default_llm
|
||||
max_context_posts = self.max_context_posts
|
||||
vision_enabled = self.vision_enabled
|
||||
vision_max_pixels = self.vision_max_pixels
|
||||
rag_conversation_chunks = self.rag_conversation_chunks
|
||||
question_consolidator_llm = self.question_consolidator_llm
|
||||
|
||||
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
|
||||
if persona_class
|
||||
@@ -145,22 +122,6 @@ class AiPersona < ActiveRecord::Base
|
||||
max_context_posts
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :vision_enabled do
|
||||
vision_enabled
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :vision_max_pixels do
|
||||
vision_max_pixels
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :question_consolidator_llm do
|
||||
question_consolidator_llm
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :rag_conversation_chunks do
|
||||
rag_conversation_chunks
|
||||
end
|
||||
|
||||
return persona_class
|
||||
end
|
||||
|
||||
@@ -236,22 +197,6 @@ class AiPersona < ActiveRecord::Base
|
||||
max_context_posts
|
||||
end
|
||||
|
||||
define_singleton_method :vision_enabled do
|
||||
vision_enabled
|
||||
end
|
||||
|
||||
define_singleton_method :vision_max_pixels do
|
||||
vision_max_pixels
|
||||
end
|
||||
|
||||
define_singleton_method :rag_conversation_chunks do
|
||||
rag_conversation_chunks
|
||||
end
|
||||
|
||||
define_singleton_method :question_consolidator_llm do
|
||||
question_consolidator_llm
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
@@ -265,10 +210,6 @@ class AiPersona < ActiveRecord::Base
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :persona_id do
|
||||
@ai_persona&.id
|
||||
end
|
||||
|
||||
define_method :tools do
|
||||
tools
|
||||
end
|
||||
@@ -288,10 +229,6 @@ class AiPersona < ActiveRecord::Base
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
|
||||
define_method :uploads do
|
||||
@ai_persona&.uploads
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -334,12 +271,6 @@ class AiPersona < ActiveRecord::Base
|
||||
user
|
||||
end
|
||||
|
||||
def regenerate_rag_fragments
|
||||
if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed?
|
||||
RagDocumentFragment.where(ai_persona: self).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_persona_unchangeable
|
||||
@@ -361,32 +292,26 @@ end
|
||||
#
|
||||
# Table name: ai_personas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :json not null
|
||||
# system_prompt :string(10000000) not null
|
||||
# allowed_group_ids :integer default([]), not null, is an Array
|
||||
# created_by_id :integer
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# system :boolean default(FALSE), not null
|
||||
# priority :boolean default(FALSE), not null
|
||||
# temperature :float
|
||||
# top_p :float
|
||||
# user_id :integer
|
||||
# mentionable :boolean default(FALSE), not null
|
||||
# default_llm :text
|
||||
# max_context_posts :integer
|
||||
# max_post_context_tokens :integer
|
||||
# max_context_tokens :integer
|
||||
# vision_enabled :boolean default(FALSE), not null
|
||||
# vision_max_pixels :integer default(1048576), not null
|
||||
# rag_chunk_tokens :integer default(374), not null
|
||||
# rag_chunk_overlap_tokens :integer default(10), not null
|
||||
# rag_conversation_chunks :integer default(10), not null
|
||||
# question_consolidator_llm :text
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :json not null
|
||||
# system_prompt :string(10000000) not null
|
||||
# allowed_group_ids :integer default([]), not null, is an Array
|
||||
# created_by_id :integer
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# system :boolean default(FALSE), not null
|
||||
# priority :boolean default(FALSE), not null
|
||||
# temperature :float
|
||||
# top_p :float
|
||||
# user_id :integer
|
||||
# mentionable :boolean default(FALSE), not null
|
||||
# default_llm :text
|
||||
# max_context_posts :integer
|
||||
# max_post_context_tokens :integer
|
||||
# max_context_tokens :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -7,18 +7,3 @@ end
|
||||
class ::Post
|
||||
has_one :post_custom_prompt, dependent: :destroy
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: post_custom_prompts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# post_id :integer not null
|
||||
# custom_prompt :json not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_post_custom_prompts_on_post_id (post_id) UNIQUE
|
||||
#
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RagDocumentFragment < ActiveRecord::Base
|
||||
belongs_to :upload
|
||||
belongs_to :ai_persona
|
||||
|
||||
class << self
|
||||
def link_persona_and_uploads(persona, upload_ids)
|
||||
return if persona.blank?
|
||||
return if upload_ids.blank?
|
||||
return if !SiteSetting.ai_embeddings_enabled?
|
||||
|
||||
UploadReference.ensure_exist!(upload_ids: upload_ids, target: persona)
|
||||
|
||||
upload_ids.each do |upload_id|
|
||||
Jobs.enqueue(:digest_rag_upload, ai_persona_id: persona.id, upload_id: upload_id)
|
||||
end
|
||||
end
|
||||
|
||||
def update_persona_uploads(persona, upload_ids)
|
||||
return if persona.blank?
|
||||
return if !SiteSetting.ai_embeddings_enabled?
|
||||
|
||||
if upload_ids.blank?
|
||||
RagDocumentFragment.where(ai_persona: persona).destroy_all
|
||||
UploadReference.where(target: persona).destroy_all
|
||||
else
|
||||
RagDocumentFragment.where(ai_persona: persona).where.not(upload_id: upload_ids).destroy_all
|
||||
link_persona_and_uploads(persona, upload_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def indexing_status(persona, uploads)
|
||||
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
|
||||
vector_rep =
|
||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
||||
|
||||
embeddings_table = vector_rep.rag_fragments_table_name
|
||||
|
||||
results = DB.query(<<~SQL, persona_id: persona.id, upload_ids: uploads.map(&:id))
|
||||
SELECT
|
||||
uploads.id,
|
||||
SUM(CASE WHEN (rdf.upload_id IS NOT NULL) THEN 1 ELSE 0 END) AS total,
|
||||
SUM(CASE WHEN (eft.rag_document_fragment_id IS NOT NULL) THEN 1 ELSE 0 END) as indexed,
|
||||
SUM(CASE WHEN (rdf.upload_id IS NOT NULL AND eft.rag_document_fragment_id IS NULL) THEN 1 ELSE 0 END) as left
|
||||
FROM uploads
|
||||
LEFT OUTER JOIN rag_document_fragments rdf ON uploads.id = rdf.upload_id AND rdf.ai_persona_id = :persona_id
|
||||
LEFT OUTER JOIN #{embeddings_table} eft ON rdf.id = eft.rag_document_fragment_id
|
||||
WHERE uploads.id IN (:upload_ids)
|
||||
GROUP BY uploads.id
|
||||
SQL
|
||||
|
||||
results.reduce({}) do |acc, r|
|
||||
acc[r.id] = { total: r.total, indexed: r.indexed, left: r.left }
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
def publish_status(upload, status)
|
||||
MessageBus.publish(
|
||||
"/discourse-ai/ai-persona-rag/#{upload.id}",
|
||||
status,
|
||||
user_ids: [upload.user_id],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: rag_document_fragments
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# fragment :text not null
|
||||
# upload_id :integer not null
|
||||
# ai_persona_id :integer not null
|
||||
# fragment_number :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# metadata :text
|
||||
#
|
||||
@@ -1,215 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SharedAiConversation < ActiveRecord::Base
|
||||
DEFAULT_MAX_POSTS = 100
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :target, polymorphic: true
|
||||
|
||||
validates :user_id, presence: true
|
||||
validates :target, presence: true
|
||||
validates :context, presence: true
|
||||
validates :share_key, presence: true, uniqueness: true
|
||||
|
||||
before_validation :generate_share_key, on: :create
|
||||
|
||||
def self.share_conversation(user, target, max_posts: DEFAULT_MAX_POSTS)
|
||||
raise "Target must be a topic for now" if !target.is_a?(Topic)
|
||||
|
||||
conversation = find_by(user: user, target: target)
|
||||
conversation_data = build_conversation_data(target, max_posts: max_posts)
|
||||
|
||||
conversation =
|
||||
if conversation
|
||||
conversation.update(**conversation_data)
|
||||
conversation
|
||||
else
|
||||
create(user_id: user.id, target: target, **conversation_data)
|
||||
end
|
||||
|
||||
::Jobs.enqueue(:shared_conversation_adjust_upload_security, conversation_id: conversation.id)
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
def self.destroy_conversation(conversation)
|
||||
conversation.destroy
|
||||
::Jobs.enqueue(
|
||||
:shared_conversation_adjust_upload_security,
|
||||
target_id: conversation.target_id,
|
||||
target_type: conversation.target_type,
|
||||
)
|
||||
end
|
||||
|
||||
# Technically this may end up being a chat message
|
||||
# but this name works
|
||||
class SharedPost
|
||||
attr_accessor :user
|
||||
attr_reader :id, :user_id, :created_at, :cooked, :persona
|
||||
def initialize(post)
|
||||
@id = post[:id]
|
||||
@user_id = post[:user_id]
|
||||
@created_at = DateTime.parse(post[:created_at])
|
||||
@cooked = post[:cooked]
|
||||
@persona = post[:persona]
|
||||
end
|
||||
end
|
||||
|
||||
def populated_context
|
||||
return @populated_context if @populated_context
|
||||
@populated_context = context.map { |post| SharedPost.new(post.symbolize_keys) }
|
||||
populate_user_info!(@populated_context)
|
||||
@populated_context
|
||||
end
|
||||
|
||||
def to_json
|
||||
posts =
|
||||
self.populated_context.map do |post|
|
||||
{
|
||||
id: post.id,
|
||||
cooked: post.cooked,
|
||||
username: post.user.username,
|
||||
created_at: post.created_at,
|
||||
}
|
||||
end
|
||||
{ llm_name: self.llm_name, share_key: self.share_key, title: self.title, posts: posts }
|
||||
end
|
||||
|
||||
def url
|
||||
"#{Discourse.base_uri}/discourse-ai/ai-bot/shared-ai-conversations/#{share_key}"
|
||||
end
|
||||
|
||||
def html_excerpt
|
||||
html = +""
|
||||
populated_context.each do |post|
|
||||
text =
|
||||
PrettyText.excerpt(
|
||||
post.cooked,
|
||||
400,
|
||||
text_entities: true,
|
||||
strip_links: true,
|
||||
strip_details: true,
|
||||
)
|
||||
|
||||
html << "<p><b>#{post.user.username}</b>: #{text}</p>"
|
||||
if html.length > 1000
|
||||
html << "<p>...</p>"
|
||||
break
|
||||
end
|
||||
end
|
||||
html << "<a href='#{url}'>#{I18n.t("discourse_ai.share_ai.read_more")}<a>"
|
||||
html
|
||||
end
|
||||
|
||||
def onebox
|
||||
<<~HTML
|
||||
<div>
|
||||
<aside class="onebox allowlistedgeneric" data-onebox-src="#{url}">
|
||||
<header class="source">
|
||||
<span class="onebox-ai-llm-title">#{I18n.t("discourse_ai.share_ai.onebox_title", llm_name: llm_name)}</span>
|
||||
<a href="#{url}" target="_blank" rel="nofollow ugc noopener" tabindex="-1">#{Discourse.base_uri}</a>
|
||||
</header>
|
||||
<article class="onebox-body">
|
||||
<h3><a href="#{url}" rel="nofollow ugc noopener" tabindex="-1">#{title}</a></h3>
|
||||
#{html_excerpt}
|
||||
</article>
|
||||
<div style="clear: both"></div>
|
||||
</aside>
|
||||
</div>
|
||||
HTML
|
||||
end
|
||||
|
||||
def self.excerpt(posts)
|
||||
excerpt = +""
|
||||
posts.each do |post|
|
||||
excerpt << "#{post.user.display_name}: #{post.excerpt(100)} "
|
||||
break if excerpt.length > 1000
|
||||
end
|
||||
excerpt
|
||||
end
|
||||
|
||||
def formatted_excerpt
|
||||
I18n.t("discourse_ai.share_ai.formatted_excerpt", llm_name: llm_name, excerpt: excerpt)
|
||||
end
|
||||
|
||||
def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_usernames: false)
|
||||
llm_name = nil
|
||||
topic.topic_allowed_users.each do |tu|
|
||||
if DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(tu.user_id)
|
||||
llm_name = DiscourseAi::AiBot::EntryPoint.find_bot_by_id(tu.user_id)&.llm
|
||||
end
|
||||
end
|
||||
|
||||
llm_name = ActiveSupport::Inflector.humanize(llm_name) if llm_name
|
||||
llm_name ||= I18n.t("discourse_ai.unknown_model")
|
||||
|
||||
persona = nil
|
||||
if persona_id = topic.custom_fields["ai_persona_id"]
|
||||
persona = AiPersona.find_by(id: persona_id.to_i)&.name
|
||||
end
|
||||
|
||||
posts =
|
||||
topic
|
||||
.posts
|
||||
.by_post_number
|
||||
.where(post_type: Post.types[:regular])
|
||||
.where.not(cooked: nil)
|
||||
.where(deleted_at: nil)
|
||||
.limit(max_posts)
|
||||
|
||||
{
|
||||
llm_name: llm_name,
|
||||
title: topic.title,
|
||||
excerpt: excerpt(posts),
|
||||
context:
|
||||
posts.map do |post|
|
||||
mapped = {
|
||||
id: post.id,
|
||||
user_id: post.user_id,
|
||||
created_at: post.created_at,
|
||||
cooked: post.cooked,
|
||||
}
|
||||
|
||||
mapped[:persona] = persona if ::DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(
|
||||
post.user_id,
|
||||
)
|
||||
mapped[:username] = post.user&.username if include_usernames
|
||||
mapped
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def populate_user_info!(posts)
|
||||
users = User.where(id: posts.map(&:user_id).uniq).map { |u| [u.id, u] }.to_h
|
||||
posts.each { |post| post.user = users[post.user_id] }
|
||||
end
|
||||
|
||||
def generate_share_key
|
||||
self.share_key = SecureRandom.urlsafe_base64(16)
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: shared_ai_conversations
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer not null
|
||||
# target_id :integer not null
|
||||
# target_type :string not null
|
||||
# title :string not null
|
||||
# llm_name :string not null
|
||||
# context :jsonb not null
|
||||
# share_key :string not null
|
||||
# excerpt :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_shared_ai_conversations_user_target (user_id,target_id,target_type) UNIQUE
|
||||
# index_shared_ai_conversations_on_share_key (share_key) UNIQUE
|
||||
# index_shared_ai_conversations_on_target_id_and_target_type (target_id,target_type) UNIQUE
|
||||
#
|
||||
@@ -17,20 +17,9 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||
:mentionable,
|
||||
:default_llm,
|
||||
:user_id,
|
||||
:max_context_posts,
|
||||
:vision_enabled,
|
||||
:vision_max_pixels,
|
||||
:rag_chunk_tokens,
|
||||
:rag_chunk_overlap_tokens,
|
||||
:rag_conversation_chunks,
|
||||
:question_consolidator_llm
|
||||
:max_context_posts
|
||||
|
||||
has_one :user, serializer: BasicUserSerializer, embed: :object
|
||||
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
|
||||
|
||||
def rag_uploads
|
||||
object.uploads
|
||||
end
|
||||
|
||||
def name
|
||||
object.class_instance.name
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
resource: "admin.adminPlugins",
|
||||
|
||||
path: "/plugins",
|
||||
|
||||
map() {
|
||||
this.route("discourse-ai", function () {
|
||||
this.route("ai-personas", function () {
|
||||
this.route("new");
|
||||
this.route("show", { path: "/:id" });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
export default {
|
||||
resource: "admin.adminPlugins.show",
|
||||
|
||||
path: "/plugins",
|
||||
|
||||
map() {
|
||||
this.route("discourse-ai-personas", { path: "ai-personas" }, function () {
|
||||
this.route("new");
|
||||
this.route("show", { path: "/:id" });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,7 @@ import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
const CREATE_ATTRIBUTES = [
|
||||
"id",
|
||||
const ATTRIBUTES = [
|
||||
"name",
|
||||
"description",
|
||||
"commands",
|
||||
@@ -19,33 +18,6 @@ const CREATE_ATTRIBUTES = [
|
||||
"default_llm",
|
||||
"user",
|
||||
"max_context_posts",
|
||||
"vision_enabled",
|
||||
"vision_max_pixels",
|
||||
"rag_uploads",
|
||||
"rag_chunk_tokens",
|
||||
"rag_chunk_overlap_tokens",
|
||||
"rag_conversation_chunks",
|
||||
"question_consolidator_llm",
|
||||
];
|
||||
|
||||
const SYSTEM_ATTRIBUTES = [
|
||||
"id",
|
||||
"allowed_group_ids",
|
||||
"enabled",
|
||||
"system",
|
||||
"priority",
|
||||
"user_id",
|
||||
"mentionable",
|
||||
"default_llm",
|
||||
"user",
|
||||
"max_context_posts",
|
||||
"vision_enabled",
|
||||
"vision_max_pixels",
|
||||
"rag_uploads",
|
||||
"rag_chunk_tokens",
|
||||
"rag_chunk_overlap_tokens",
|
||||
"rag_conversation_chunks",
|
||||
"question_consolidator_llm",
|
||||
];
|
||||
|
||||
class CommandOption {
|
||||
@@ -125,24 +97,19 @@ export default class AiPersona extends RestModel {
|
||||
}
|
||||
|
||||
updateProperties() {
|
||||
let attrs = this.system
|
||||
? this.getProperties(SYSTEM_ATTRIBUTES)
|
||||
: this.getProperties(CREATE_ATTRIBUTES);
|
||||
let attrs = this.getProperties(ATTRIBUTES);
|
||||
attrs.id = this.id;
|
||||
this.populateCommandOptions(attrs);
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
createProperties() {
|
||||
let attrs = this.getProperties(CREATE_ATTRIBUTES);
|
||||
let attrs = this.getProperties(ATTRIBUTES);
|
||||
this.populateCommandOptions(attrs);
|
||||
return attrs;
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
let attrs = this.getProperties(CREATE_ATTRIBUTES);
|
||||
this.populateCommandOptions(attrs);
|
||||
return AiPersona.create(attrs);
|
||||
return AiPersona.create(this.createProperties());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { gt } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
import { composeAiBotMessage } from "../lib/ai-bot-helper";
|
||||
import AiBotHeaderPanel from "./ai-bot-header-panel";
|
||||
|
||||
export default class AiBotHeaderIcon extends Component {
|
||||
@service siteSettings;
|
||||
@service composer;
|
||||
|
||||
get bots() {
|
||||
return this.siteSettings.ai_bot_add_to_header
|
||||
? this.siteSettings.ai_bot_enabled_chat_bots.split("|").filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
@action
|
||||
compose() {
|
||||
composeAiBotMessage(this.bots[0], this.composer);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if (gt this.bots.length 0)}}
|
||||
<li>
|
||||
{{#if (gt this.bots.length 1)}}
|
||||
<DMenu
|
||||
@icon="robot"
|
||||
@title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
|
||||
class="ai-bot-button icon btn-flat"
|
||||
>
|
||||
<:content as |args|>
|
||||
<AiBotHeaderPanel @closePanel={{args.close}} />
|
||||
</:content>
|
||||
</DMenu>
|
||||
{{else}}
|
||||
<DButton
|
||||
@icon="robot"
|
||||
@title={{i18n "discourse_ai.ai_bot.shortcut_title"}}
|
||||
class="ai-bot-button icon btn-flat"
|
||||
@action={{this.compose}}
|
||||
/>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
<div class="bot-panel ai-bot-available-bot-options">
|
||||
{{#each this.botNames as |bot|}}
|
||||
<DButton
|
||||
@translatedTitle={{bot.humanized}}
|
||||
@translatedLabel={{bot.humanized}}
|
||||
@action={{action "composeMessageWithTargetBot" bot.modelName}}
|
||||
class="btn-flat ai-bot-available-bot-content"
|
||||
/>
|
||||
{{/each}}
|
||||
<div class="menu-panel drop-down">
|
||||
<div class="panel-body">
|
||||
<div class="panel-body-contents">
|
||||
<div class="sidebar-hamburger-dropdown">
|
||||
{{#each this.botNames as |bot|}}
|
||||
<DButton
|
||||
@translatedTitle={{bot.humanized}}
|
||||
@translatedLabel={{bot.humanized}}
|
||||
@action={{action "composeMessageWithTargetBot" bot.modelName}}
|
||||
class="btn-flat ai-bot-available-bot-content"
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,9 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { LinkTo } from "@ember/routing";
|
||||
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { cook } from "discourse/lib/text";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
import AiPersonaEditor from "./ai-persona-editor";
|
||||
|
||||
@@ -27,93 +21,45 @@ export default class AiPersonaListEditor extends Component {
|
||||
return this._noPersonaText;
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleEnabled(persona) {
|
||||
const oldValue = persona.enabled;
|
||||
const newValue = !oldValue;
|
||||
|
||||
try {
|
||||
persona.set("enabled", newValue);
|
||||
await persona.save();
|
||||
} catch (err) {
|
||||
persona.set("enabled", oldValue);
|
||||
popupAjaxError(err);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<section class="ai-persona-list-editor__current admin-detail pull-left">
|
||||
<div class="ai-persona-list-editor__header">
|
||||
<h3>{{I18n.t "discourse_ai.ai_persona.title"}}</h3>
|
||||
{{#unless @currentPersona.isNew}}
|
||||
<LinkTo
|
||||
@route="adminPlugins.discourse-ai.ai-personas.new"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{icon "plus"}}
|
||||
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
|
||||
</LinkTo>
|
||||
{{/unless}}
|
||||
</div>
|
||||
<div class="content-list ai-persona-list-editor">
|
||||
<ul>
|
||||
{{#each @personas as |persona|}}
|
||||
<li
|
||||
class={{concatClass
|
||||
(if persona.enabled "" "diabled")
|
||||
(if persona.priority "priority")
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="adminPlugins.discourse-ai.ai-personas.show"
|
||||
current-when="true"
|
||||
@model={{persona}}
|
||||
>{{persona.name}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<section class="ai-persona-list-editor__current content-body">
|
||||
{{#if @currentPersona}}
|
||||
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
|
||||
{{else}}
|
||||
<div class="ai-persona-list-editor__header">
|
||||
<h3>{{i18n "discourse_ai.ai_persona.short_title"}}</h3>
|
||||
{{#unless @currentPersona.isNew}}
|
||||
<LinkTo
|
||||
@route="adminPlugins.show.discourse-ai-personas.new"
|
||||
class="btn btn-small btn-primary"
|
||||
>
|
||||
{{icon "plus"}}
|
||||
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
|
||||
</LinkTo>
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
<div class="ai-persona-list-editor__empty">
|
||||
<details class="details__boxed">
|
||||
<summary>{{i18n
|
||||
"discourse_ai.ai_persona.what_are_personas"
|
||||
}}</summary>
|
||||
{{this.noPersonaText}}
|
||||
</details>
|
||||
{{this.noPersonaText}}
|
||||
</div>
|
||||
|
||||
<table class="content-list ai-persona-list-editor">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{i18n "discourse_ai.ai_persona.name"}}</th>
|
||||
<th>{{i18n "discourse_ai.ai_persona.enabled"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each @personas as |persona|}}
|
||||
<tr
|
||||
data-persona-id={{persona.id}}
|
||||
class={{concatClass
|
||||
"ai-persona-list__row"
|
||||
(if persona.priority "priority")
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<div class="ai-persona-list__name-with-description">
|
||||
<div class="ai-persona-list__name">
|
||||
<strong>
|
||||
{{persona.name}}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="ai-persona-list__description">
|
||||
{{persona.description}}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<DToggleSwitch
|
||||
@state={{persona.enabled}}
|
||||
{{on "click" (fn this.toggleEnabled persona)}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<LinkTo
|
||||
@route="adminPlugins.show.discourse-ai-personas.show"
|
||||
@model={{persona}}
|
||||
class="btn btn-text btn-small"
|
||||
>{{i18n "discourse_ai.ai_persona.edit"}} </LinkTo>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { next } from "@ember/runloop";
|
||||
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 { clipboardCopy, escapeExpression } from "discourse/lib/utilities";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
export default class DebugAiModal extends Component {
|
||||
@tracked info = null;
|
||||
@tracked justCopiedText = "";
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
next(() => {
|
||||
this.loadApiRequestInfo();
|
||||
});
|
||||
}
|
||||
|
||||
get htmlContext() {
|
||||
if (!this.info) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let parsed;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(this.info.raw_request_payload);
|
||||
} catch (e) {
|
||||
return this.info.raw_request_payload;
|
||||
}
|
||||
|
||||
return htmlSafe(this.jsonToHtml(parsed));
|
||||
}
|
||||
|
||||
jsonToHtml(json) {
|
||||
let html = "<ul>";
|
||||
for (let key in json) {
|
||||
if (!json.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
html += "<li>";
|
||||
if (typeof json[key] === "object" && Array.isArray(json[key])) {
|
||||
html += `<strong>${escapeExpression(key)}:</strong> ${this.jsonToHtml(
|
||||
json[key]
|
||||
)}`;
|
||||
} else if (typeof json[key] === "object") {
|
||||
html += `<strong>${escapeExpression(
|
||||
key
|
||||
)}:</strong> <ul><li>${this.jsonToHtml(json[key])}</li></ul>`;
|
||||
} else {
|
||||
let value = json[key];
|
||||
if (typeof value === "string") {
|
||||
value = escapeExpression(value);
|
||||
value = value.replace(/\n/g, "<br>");
|
||||
}
|
||||
html += `<strong>${escapeExpression(key)}:</strong> ${value}`;
|
||||
}
|
||||
html += "</li>";
|
||||
}
|
||||
html += "</ul>";
|
||||
return html;
|
||||
}
|
||||
|
||||
@action
|
||||
copyRequest() {
|
||||
this.copy(this.info.raw_request_payload);
|
||||
}
|
||||
|
||||
@action
|
||||
copyResponse() {
|
||||
this.copy(this.info.raw_response_payload);
|
||||
}
|
||||
|
||||
copy(text) {
|
||||
clipboardCopy(text);
|
||||
this.justCopiedText = I18n.t("discourse_ai.ai_bot.conversation_shared");
|
||||
|
||||
discourseLater(() => {
|
||||
this.justCopiedText = "";
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
loadApiRequestInfo() {
|
||||
ajax(
|
||||
`/discourse-ai/ai-bot/post/${this.args.model.id}/show-debug-info.json`
|
||||
).then((result) => {
|
||||
this.info = result;
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<DModal
|
||||
class="ai-debug-modal"
|
||||
@title={{i18n "discourse_ai.ai_bot.debug_ai_modal.title"}}
|
||||
@closeModal={{@closeModal}}
|
||||
>
|
||||
<:body>
|
||||
<div class="ai-debug-modal__tokens">
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_bot.debug_ai_modal.request_tokens"}}
|
||||
{{this.info.request_tokens}}
|
||||
</span>
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_bot.debug_ai_modal.response_tokens"}}
|
||||
{{this.info.response_tokens}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="debug-ai-modal__preview">
|
||||
{{this.htmlContext}}
|
||||
</div>
|
||||
</:body>
|
||||
|
||||
<:footer>
|
||||
<DButton
|
||||
class="btn confirm"
|
||||
@icon="copy"
|
||||
@action={{this.copyRequest}}
|
||||
@label="discourse_ai.ai_bot.debug_ai_modal.copy_request"
|
||||
/>
|
||||
<DButton
|
||||
class="btn confirm"
|
||||
@icon="copy"
|
||||
@action={{this.copyResponse}}
|
||||
@label="discourse_ai.ai_bot.debug_ai_modal.copy_response"
|
||||
/>
|
||||
<span class="ai-debut-modal__just-copied">{{this.justCopiedText}}</span>
|
||||
</:footer>
|
||||
</DModal>
|
||||
</template>
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user