Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
943aa7468d | ||
|
|
9505a8976c | ||
|
|
98022d7d96 | ||
|
|
fa7ca8bc31 | ||
|
|
772ee934ab | ||
|
|
bffe9dfa07 | ||
|
|
c352054d4e | ||
|
|
7e3a543f6f | ||
|
|
32ea421408 | ||
|
|
e8f0633141 | ||
|
|
e8eed710e0 | ||
|
|
32fb023357 | ||
|
|
00e4a84305 | ||
|
|
34a59b623e | ||
|
|
be0b78cacd | ||
|
|
05790a6a40 | ||
|
|
e7a66b0789 | ||
|
|
dd404c924a | ||
|
|
0f0f2a247a | ||
|
|
37b6461d68 | ||
|
|
820b506910 | ||
|
|
945f04b089 | ||
|
|
294c364a75 | ||
|
|
c479b177a7 | ||
|
|
8ded4b2e58 | ||
|
|
a2b1ea3c63 | ||
|
|
33da27e231 | ||
|
|
ec97996905 | ||
|
|
12869f2146 | ||
|
|
4923837165 | ||
|
|
3022d34613 | ||
|
|
c1fa84ad29 | ||
|
|
fc6f0a6560 | ||
|
|
9e8608b070 | ||
|
|
72111a10ae | ||
|
|
96f5f8cbd0 | ||
|
|
9af0c2e719 | ||
|
|
0aa2789437 | ||
|
|
657d103919 | ||
|
|
f1283e156d | ||
|
|
059d3b6fd2 | ||
|
|
eae7716177 | ||
|
|
b604ff9a23 | ||
|
|
c6276be63e | ||
|
|
a1f859a415 | ||
|
|
712a07c39b | ||
|
|
3533814870 | ||
|
|
6d504ab80d | ||
|
|
37c2930fbf | ||
|
|
e768fa877e | ||
|
|
27b5542357 | ||
|
|
decf1bb49d | ||
|
|
25cc03809a | ||
|
|
7919173cba | ||
|
|
792703c942 | ||
|
|
3432f6654f | ||
|
|
d7745d1ac3 | ||
|
|
bdf3b6268b | ||
|
|
c7acb4a6a0 | ||
|
|
791fad1e6a | ||
|
|
6615104389 | ||
|
|
94010a5f78 | ||
|
|
6c4c96e83c | ||
|
|
52d90cf1bc | ||
|
|
c5b323fc07 | ||
|
|
95e70474fd | ||
|
|
4f6b36147b | ||
|
|
51494db236 | ||
|
|
e1a0eb6131 | ||
|
|
3170e14acb | ||
|
|
a3c6938cb3 | ||
|
|
545500b329 | ||
|
|
c294b6d394 | ||
|
|
110a1629aa | ||
|
|
7ae6c17236 | ||
|
|
7325fb21ab | ||
|
|
62ba2fa4d7 | ||
|
|
2063b3854f | ||
|
|
c7eaea48f5 | ||
|
|
5cbc9190eb | ||
|
|
18ecc843e5 | ||
|
|
1002dc877d | ||
|
|
8cf1798afe | ||
|
|
95f80325e5 | ||
|
|
e666266473 | ||
|
|
1e155942bb | ||
|
|
101f1e9512 | ||
|
|
513510d6d0 | ||
|
|
4b21eb7974 | ||
|
|
493d65af1f | ||
|
|
03eccbe392 | ||
|
|
b16390ae2a | ||
|
|
9374cd7ac1 | ||
|
|
9cd14b0003 | ||
|
|
5b9add0ac8 | ||
|
|
22d1e71dc9 | ||
|
|
36ce88f356 | ||
|
|
a5b5c3bebe | ||
|
|
cabecb801e | ||
|
|
c4c9dc2034 | ||
|
|
a48acc894a | ||
|
|
b0ae2138af | ||
|
|
b0d1eee0ce | ||
|
|
8ea26e8cfc | ||
|
|
db5cbfb148 | ||
|
|
ed97827f49 | ||
|
|
e408cd080c | ||
|
|
4a1874265e | ||
|
|
584753cf60 | ||
|
|
eee8e72756 | ||
|
|
4176fbd3dd |
@@ -1,3 +1,4 @@
|
||||
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
|
||||
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
|
||||
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
|
||||
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
/gems
|
||||
/auto_generated
|
||||
.env
|
||||
|
||||
+1
-3
@@ -32,8 +32,7 @@ GEM
|
||||
rack (3.1.6)
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.9.2)
|
||||
rexml (3.3.3)
|
||||
strscan
|
||||
rexml (3.3.9)
|
||||
rubocop (1.64.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
@@ -70,7 +69,6 @@ GEM
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec (~> 3, >= 3.0.1)
|
||||
ruby-progressbar (1.13.0)
|
||||
strscan (3.1.0)
|
||||
syntax_tree (6.2.0)
|
||||
prettier_print (>= 1.2.0)
|
||||
tzinfo (2.0.6)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
queryParams: {
|
||||
llmTemplate: { refreshModel: true },
|
||||
},
|
||||
|
||||
async model() {
|
||||
const record = this.store.createRecord("ai-llm");
|
||||
record.provider_params = {};
|
||||
@@ -13,5 +17,9 @@ export default DiscourseRoute.extend({
|
||||
"allLlms",
|
||||
this.modelFor("adminPlugins.show.discourse-ai-llms")
|
||||
);
|
||||
controller.set(
|
||||
"llmTemplate",
|
||||
this.paramsFor(this.routeName).llmTemplate || null
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ export default DiscourseRoute.extend({
|
||||
record.set("rag_chunk_tokens", 374);
|
||||
record.set("rag_chunk_overlap_tokens", 10);
|
||||
record.set("rag_conversation_chunks", 10);
|
||||
record.set("allow_personal_messages", true);
|
||||
record.set("tool_details", false);
|
||||
return record;
|
||||
},
|
||||
|
||||
|
||||
+5
-1
@@ -1 +1,5 @@
|
||||
<AiLlmsListEditor @llms={{this.allLlms}} @currentLlm={{this.model}} />
|
||||
<AiLlmsListEditor
|
||||
@llms={{this.allLlms}}
|
||||
@currentLlm={{this.model}}
|
||||
@llmTemplate={{this.llmTemplate}}
|
||||
/>
|
||||
@@ -14,6 +14,9 @@ module DiscourseAi
|
||||
llms,
|
||||
each_serializer: LlmModelSerializer,
|
||||
root: false,
|
||||
scope: {
|
||||
llm_usage: DiscourseAi::Configuration::LlmEnumerator.global_usage,
|
||||
},
|
||||
).as_json,
|
||||
meta: {
|
||||
provider_params: LlmModel.provider_params,
|
||||
@@ -36,7 +39,7 @@ module DiscourseAi
|
||||
llm_model = LlmModel.new(ai_llm_params)
|
||||
if llm_model.save
|
||||
llm_model.toggle_companion_user
|
||||
render json: { ai_persona: LlmModelSerializer.new(llm_model) }, status: :created
|
||||
render json: LlmModelSerializer.new(llm_model), status: :created
|
||||
else
|
||||
render_json_error llm_model
|
||||
end
|
||||
|
||||
@@ -5,8 +5,7 @@ module DiscourseAi
|
||||
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 =
|
||||
@@ -41,7 +40,7 @@ module DiscourseAi
|
||||
def create
|
||||
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
|
||||
if ai_persona.save
|
||||
RagDocumentFragment.link_persona_and_uploads(ai_persona, attached_upload_ids)
|
||||
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
|
||||
|
||||
render json: {
|
||||
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
|
||||
@@ -59,7 +58,7 @@ module DiscourseAi
|
||||
|
||||
def update
|
||||
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
|
||||
RagDocumentFragment.update_persona_uploads(@ai_persona, attached_upload_ids)
|
||||
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
|
||||
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
||||
else
|
||||
@@ -75,39 +74,93 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
|
||||
def upload_file
|
||||
file = params[:file] || params[:files].first
|
||||
def stream_reply
|
||||
persona =
|
||||
AiPersona.find_by(name: params[:persona_name]) ||
|
||||
AiPersona.find_by(id: params[:persona_id])
|
||||
return render_json_error(I18n.t("discourse_ai.errors.persona_not_found")) if persona.nil?
|
||||
|
||||
if !SiteSetting.ai_embeddings_enabled?
|
||||
raise Discourse::InvalidAccess.new("Embeddings not enabled")
|
||||
return render_json_error(I18n.t("discourse_ai.errors.persona_disabled")) if !persona.enabled
|
||||
|
||||
if persona.default_llm.blank?
|
||||
return render_json_error(I18n.t("discourse_ai.errors.no_default_llm"))
|
||||
end
|
||||
|
||||
validate_extension!(file.original_filename)
|
||||
validate_file_size!(file.tempfile.size)
|
||||
if params[:query].blank?
|
||||
return render_json_error(I18n.t("discourse_ai.errors.no_query_specified"))
|
||||
end
|
||||
|
||||
hijack do
|
||||
upload =
|
||||
UploadCreator.new(
|
||||
file.tempfile,
|
||||
file.original_filename,
|
||||
type: "discourse_ai_rag_upload",
|
||||
skip_validations: true,
|
||||
).create_for(current_user.id)
|
||||
if !persona.user_id
|
||||
return render_json_error(I18n.t("discourse_ai.errors.no_user_for_persona"))
|
||||
end
|
||||
|
||||
if upload.persisted?
|
||||
render json: UploadSerializer.new(upload)
|
||||
else
|
||||
render json: failed_json.merge(errors: upload.errors.full_messages), status: 422
|
||||
if !params[:username] && !params[:user_unique_id]
|
||||
return render_json_error(I18n.t("discourse_ai.errors.no_user_specified"))
|
||||
end
|
||||
|
||||
user = nil
|
||||
|
||||
if params[:username]
|
||||
user = User.find_by_username(params[:username])
|
||||
return render_json_error(I18n.t("discourse_ai.errors.user_not_found")) if user.nil?
|
||||
elsif params[:user_unique_id]
|
||||
user = stage_user
|
||||
end
|
||||
|
||||
raise Discourse::NotFound if user.nil?
|
||||
|
||||
topic_id = params[:topic_id].to_i
|
||||
topic = nil
|
||||
|
||||
if topic_id > 0
|
||||
topic = Topic.find(topic_id)
|
||||
|
||||
if topic.topic_allowed_users.where(user_id: user.id).empty?
|
||||
return render_json_error(I18n.t("discourse_ai.errors.user_not_allowed"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def indexing_status_check
|
||||
render json: RagDocumentFragment.indexing_status(@ai_persona, @ai_persona.uploads)
|
||||
hijack = request.env["rack.hijack"]
|
||||
io = hijack.call
|
||||
|
||||
DiscourseAi::AiBot::ResponseHttpStreamer.queue_streamed_reply(
|
||||
io: io,
|
||||
persona: persona,
|
||||
user: user,
|
||||
topic: topic,
|
||||
query: params[:query].to_s,
|
||||
custom_instructions: params[:custom_instructions].to_s,
|
||||
current_user: current_user,
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
AI_STREAM_CONVERSATION_UNIQUE_ID = "ai-stream-conversation-unique-id"
|
||||
|
||||
def stage_user
|
||||
unique_id = params[:user_unique_id].to_s
|
||||
field = UserCustomField.find_by(name: AI_STREAM_CONVERSATION_UNIQUE_ID, value: unique_id)
|
||||
|
||||
if field
|
||||
field.user
|
||||
else
|
||||
preferred_username = params[:preferred_username]
|
||||
username = UserNameSuggester.suggest(preferred_username || unique_id)
|
||||
|
||||
user =
|
||||
User.new(
|
||||
username: username,
|
||||
email: "#{SecureRandom.hex}@invalid.com",
|
||||
staged: true,
|
||||
active: false,
|
||||
)
|
||||
user.custom_fields[AI_STREAM_CONVERSATION_UNIQUE_ID] = unique_id
|
||||
user.save!
|
||||
user
|
||||
end
|
||||
end
|
||||
|
||||
def find_ai_persona
|
||||
@ai_persona = AiPersona.find(params[:id])
|
||||
end
|
||||
@@ -128,7 +181,6 @@ module DiscourseAi
|
||||
:temperature,
|
||||
:default_llm,
|
||||
:user_id,
|
||||
:mentionable,
|
||||
:max_context_posts,
|
||||
:vision_enabled,
|
||||
:vision_max_pixels,
|
||||
@@ -136,8 +188,13 @@ module DiscourseAi
|
||||
:rag_chunk_overlap_tokens,
|
||||
:rag_conversation_chunks,
|
||||
:question_consolidator_llm,
|
||||
:allow_chat,
|
||||
:allow_chat_channel_mentions,
|
||||
:allow_chat_direct_messages,
|
||||
:allow_topic_mentions,
|
||||
:allow_personal_messages,
|
||||
:tool_details,
|
||||
:forced_tool_count,
|
||||
:force_default_llm,
|
||||
allowed_group_ids: [],
|
||||
rag_uploads: [:id],
|
||||
)
|
||||
@@ -152,40 +209,12 @@ module DiscourseAi
|
||||
def permit_tools(tools)
|
||||
return [] if !tools.is_a?(Array)
|
||||
|
||||
tools.filter_map do |tool, options|
|
||||
tools.filter_map do |tool, options, force_tool|
|
||||
break nil if !tool.is_a?(String)
|
||||
options&.permit! if options && options.is_a?(ActionController::Parameters)
|
||||
|
||||
if options
|
||||
[tool, options]
|
||||
else
|
||||
tool
|
||||
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),
|
||||
),
|
||||
)
|
||||
# this is simpler from a storage perspective, 1 way to store tools
|
||||
[tool, options, !!force_tool]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ module DiscourseAi
|
||||
class AiToolsController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
before_action :find_ai_tool, only: %i[show update destroy]
|
||||
before_action :find_ai_tool, only: %i[test show update destroy]
|
||||
|
||||
def index
|
||||
ai_tools = AiTool.all
|
||||
@@ -21,6 +21,7 @@ module DiscourseAi
|
||||
ai_tool.created_by_id = current_user.id
|
||||
|
||||
if ai_tool.save
|
||||
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
|
||||
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
|
||||
else
|
||||
render_json_error ai_tool
|
||||
@@ -29,6 +30,7 @@ module DiscourseAi
|
||||
|
||||
def update
|
||||
if @ai_tool.update(ai_tool_params)
|
||||
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
|
||||
render_serialized(@ai_tool, AiCustomToolSerializer)
|
||||
else
|
||||
render_json_error @ai_tool
|
||||
@@ -44,18 +46,13 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def test
|
||||
if params[:id].present?
|
||||
ai_tool = AiTool.find(params[:id])
|
||||
else
|
||||
ai_tool = AiTool.new(ai_tool_params)
|
||||
end
|
||||
|
||||
@ai_tool.assign_attributes(ai_tool_params) if params[:ai_tool]
|
||||
parameters = params[:parameters].to_unsafe_h
|
||||
|
||||
# we need an llm so we have a tokenizer
|
||||
# but will do without if none is available
|
||||
llm = LlmModel.first&.to_llm
|
||||
runner = ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {})
|
||||
runner = @ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {})
|
||||
result = runner.invoke
|
||||
|
||||
if result.is_a?(Hash) && result[:error]
|
||||
@@ -71,18 +68,28 @@ module DiscourseAi
|
||||
|
||||
private
|
||||
|
||||
def attached_upload_ids
|
||||
params[:ai_tool][:rag_uploads].to_a.map { |h| h[:id] }
|
||||
end
|
||||
|
||||
def find_ai_tool
|
||||
@ai_tool = AiTool.find(params[:id])
|
||||
@ai_tool = AiTool.find(params[:id].to_i)
|
||||
end
|
||||
|
||||
def ai_tool_params
|
||||
params.require(:ai_tool).permit(
|
||||
:name,
|
||||
:description,
|
||||
:script,
|
||||
:summary,
|
||||
parameters: [:name, :type, :description, :required, enum: []],
|
||||
)
|
||||
params
|
||||
.require(:ai_tool)
|
||||
.permit(
|
||||
:name,
|
||||
:description,
|
||||
:script,
|
||||
:summary,
|
||||
:rag_chunk_tokens,
|
||||
:rag_chunk_overlap_tokens,
|
||||
rag_uploads: [:id],
|
||||
parameters: [:name, :type, :description, :required, enum: []],
|
||||
)
|
||||
.except(:rag_uploads)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class RagDocumentFragmentsController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def indexing_status_check
|
||||
if params[:target_type] == "AiPersona"
|
||||
@target = AiPersona.find(params[:target_id])
|
||||
elsif params[:target_type] == "AiTool"
|
||||
@target = AiTool.find(params[:target_id])
|
||||
else
|
||||
raise Discourse::InvalidParameters.new("Invalid target type")
|
||||
end
|
||||
|
||||
render json: RagDocumentFragment.indexing_status(@target, @target.uploads)
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
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,10 +6,21 @@ module DiscourseAi
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
before_action :ensure_can_request_suggestions
|
||||
before_action :rate_limiter_performed!, except: %i[prompts]
|
||||
before_action :rate_limiter_performed!
|
||||
|
||||
include SecureUploadEndpointHelpers
|
||||
|
||||
RATE_LIMITS = {
|
||||
"default" => {
|
||||
amount: 6,
|
||||
interval: 3.minutes,
|
||||
},
|
||||
"caption_image" => {
|
||||
amount: 20,
|
||||
interval: 1.minute,
|
||||
},
|
||||
}.freeze
|
||||
|
||||
def suggest
|
||||
input = get_text_param!
|
||||
force_default_locale = params[:force_default_locale] || false
|
||||
@@ -161,7 +172,13 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def rate_limiter_performed!
|
||||
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||
action_rate_limit = RATE_LIMITS[action_name] || RATE_LIMITS["default"]
|
||||
RateLimiter.new(
|
||||
current_user,
|
||||
"ai_assistant",
|
||||
action_rate_limit[:amount],
|
||||
action_rate_limit[:interval],
|
||||
).performed!
|
||||
end
|
||||
|
||||
def ensure_can_request_suggestions
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Discord
|
||||
class BotController < ::ApplicationController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
def interactions
|
||||
# Request signature verification
|
||||
begin
|
||||
verify_request!
|
||||
rescue Ed25519::VerifyError
|
||||
return head :unauthorized
|
||||
end
|
||||
|
||||
body = request.body.read
|
||||
interaction = JSON.parse(body, object_class: OpenStruct)
|
||||
|
||||
if interaction.type == 1
|
||||
# Respond to Discord PING request
|
||||
render json: { type: 1 }
|
||||
else
|
||||
if !SiteSetting.ai_discord_allowed_guilds_map.include?(interaction.guild_id)
|
||||
return head :forbidden
|
||||
end
|
||||
|
||||
response = { type: 5, data: { content: "Searching..." } }
|
||||
hijack { render json: response }
|
||||
|
||||
# Respond to Discord command
|
||||
Jobs.enqueue(:stream_discord_reply, interaction: body)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_request!
|
||||
signature = request.headers["X-Signature-Ed25519"]
|
||||
timestamp = request.headers["X-Signature-Timestamp"]
|
||||
verify_key.verify([signature].pack("H*"), "#{timestamp}#{request.raw_post}")
|
||||
end
|
||||
|
||||
def verify_key
|
||||
Ed25519::VerifyKey.new([SiteSetting.ai_discord_app_public_key].pack("H*")).freeze
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,9 +7,12 @@ module DiscourseAi
|
||||
|
||||
SEMANTIC_SEARCH_TYPE = "semantic_search"
|
||||
|
||||
MAX_HYDE_SEARCHES_PER_MINUTE = 4
|
||||
MAX_SEARCHES_PER_MINUTE = 100
|
||||
|
||||
def search
|
||||
query = params[:q].to_s
|
||||
skip_hyde = params[:hyde].downcase.to_s == "false" || params[:hyde].to_s == "0"
|
||||
skip_hyde = params[:hyde].to_s.downcase == "false" || params[:hyde].to_s == "0"
|
||||
|
||||
if query.length < SiteSetting.min_search_term_length
|
||||
raise Discourse::InvalidParameters.new(:q)
|
||||
@@ -26,8 +29,20 @@ module DiscourseAi
|
||||
|
||||
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
|
||||
|
||||
if !semantic_search.cached_query?(query)
|
||||
RateLimiter.new(current_user, "semantic-search", 4, 1.minutes).performed!
|
||||
if !skip_hyde && !semantic_search.cached_query?(query)
|
||||
RateLimiter.new(
|
||||
current_user,
|
||||
"semantic-search",
|
||||
MAX_HYDE_SEARCHES_PER_MINUTE,
|
||||
1.minutes,
|
||||
).performed!
|
||||
else
|
||||
RateLimiter.new(
|
||||
current_user,
|
||||
"semantic-search-non-hyde",
|
||||
MAX_SEARCHES_PER_MINUTE,
|
||||
1.minutes,
|
||||
).performed!
|
||||
end
|
||||
|
||||
hijack do
|
||||
|
||||
@@ -15,29 +15,21 @@ module DiscourseAi
|
||||
channel = ::Chat::Channel.find(params[:channel_id])
|
||||
guardian.ensure_can_join_chat_channel!(channel)
|
||||
|
||||
strategy = DiscourseAi::Summarization.default_strategy
|
||||
raise Discourse::NotFound.new unless strategy
|
||||
summarizer = DiscourseAi::Summarization.chat_channel_summary(channel, since)
|
||||
raise Discourse::NotFound.new unless summarizer
|
||||
|
||||
guardian.ensure_can_request_summary!
|
||||
|
||||
RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed!
|
||||
|
||||
hijack do
|
||||
content = { content_title: channel.name }
|
||||
|
||||
content[:contents] = channel
|
||||
.chat_messages
|
||||
.where("chat_messages.created_at > ?", since.hours.ago)
|
||||
.includes(:user)
|
||||
.order(created_at: :asc)
|
||||
.pluck(:id, :username_lower, :message)
|
||||
.map { { id: _1, poster: _2, text: _3 } }
|
||||
strategy = DiscourseAi::Summarization::Strategies::ChatMessages.new(channel, since)
|
||||
|
||||
summarized_text =
|
||||
if content[:contents].empty?
|
||||
if strategy.targets_data.empty?
|
||||
I18n.t("discourse_ai.summarization.chat.no_targets")
|
||||
else
|
||||
strategy.summarize(content, current_user).dig(:summary)
|
||||
summarizer.summarize(current_user)&.summarized_text
|
||||
end
|
||||
|
||||
render json: { summary: summarized_text }
|
||||
|
||||
@@ -16,8 +16,10 @@ module DiscourseAi
|
||||
opts = params.permit(:skip_age_check)
|
||||
skip_age_check = opts[:skip_age_check] == "true"
|
||||
|
||||
summarization_service = DiscourseAi::TopicSummarization.for(topic, current_user)
|
||||
|
||||
if params[:stream] && current_user
|
||||
cached_summary = DiscourseAi::TopicSummarization.cached_summary(topic, current_user)
|
||||
cached_summary = summarization_service.cached_summary
|
||||
|
||||
if cached_summary && !skip_age_check
|
||||
render_serialized(cached_summary, AiTopicSummarySerializer)
|
||||
@@ -34,12 +36,7 @@ module DiscourseAi
|
||||
render json: success_json
|
||||
else
|
||||
hijack do
|
||||
summary =
|
||||
DiscourseAi::TopicSummarization.summarize(
|
||||
topic,
|
||||
current_user,
|
||||
skip_age_check: skip_age_check,
|
||||
)
|
||||
summary = summarization_service.summarize(skip_age_check: skip_age_check)
|
||||
render_serialized(summary, AiTopicSummarySerializer)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,17 +9,24 @@ module ::Jobs
|
||||
# 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?
|
||||
|
||||
target_type = args[:target_type]
|
||||
target_id = args[:target_id]
|
||||
|
||||
return if !target_type || !target_id
|
||||
|
||||
target = target_type.constantize.find_by(id: target_id)
|
||||
return if !target
|
||||
|
||||
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
|
||||
chunk_tokens = target.rag_chunk_tokens
|
||||
overlap_tokens = target.rag_chunk_overlap_tokens
|
||||
|
||||
fragment_ids = RagDocumentFragment.where(ai_persona: ai_persona, upload: upload).pluck(:id)
|
||||
fragment_ids = RagDocumentFragment.where(target: target, upload: upload).pluck(:id)
|
||||
|
||||
# Check if this is the first time we process this upload.
|
||||
if fragment_ids.empty?
|
||||
@@ -39,7 +46,7 @@ module ::Jobs
|
||||
overlap_tokens: overlap_tokens,
|
||||
) do |chunk, metadata|
|
||||
fragment_ids << RagDocumentFragment.create!(
|
||||
ai_persona: ai_persona,
|
||||
target: target,
|
||||
fragment: chunk,
|
||||
fragment_number: idx + 1,
|
||||
upload: upload,
|
||||
@@ -119,7 +126,9 @@ module ::Jobs
|
||||
|
||||
while overlap_token_ids.present?
|
||||
begin
|
||||
overlap = tokenizer.decode(overlap_token_ids) + split_char
|
||||
padding = split_char
|
||||
padding = " " if padding.empty?
|
||||
overlap = tokenizer.decode(overlap_token_ids) + padding
|
||||
break if overlap.encoding == Encoding::UTF_8
|
||||
rescue StandardError
|
||||
# it is possible that we truncated mid char
|
||||
@@ -128,7 +137,7 @@ module ::Jobs
|
||||
end
|
||||
|
||||
# remove first word it is probably truncated
|
||||
overlap = overlap.split(" ", 2).last
|
||||
overlap = overlap.split(/\s/, 2).last.to_s.lstrip
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ module ::Jobs
|
||||
fragments.map { |fragment| vector_rep.generate_representation_from(fragment) }
|
||||
|
||||
last_fragment = fragments.last
|
||||
ai_persona = last_fragment.ai_persona
|
||||
target = last_fragment.target
|
||||
upload = last_fragment.upload
|
||||
|
||||
indexing_status = RagDocumentFragment.indexing_status(ai_persona, [upload])[upload.id]
|
||||
indexing_status = RagDocumentFragment.indexing_status(target, [upload])[upload.id]
|
||||
RagDocumentFragment.publish_status(upload, indexing_status)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class HotTopicsGistBatch < ::Jobs::Base
|
||||
def execute(_args)
|
||||
return if !SiteSetting.discourse_ai_enabled
|
||||
return if !SiteSetting.ai_summarization_enabled
|
||||
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
|
||||
|
||||
Topic
|
||||
.joins("JOIN topic_hot_scores on topics.id = topic_hot_scores.topic_id")
|
||||
.order("topic_hot_scores.score DESC")
|
||||
.limit(SiteSetting.ai_summarize_max_hot_topics_gists_per_batch)
|
||||
.each do |topic|
|
||||
summarizer = DiscourseAi::Summarization.topic_gist(topic)
|
||||
gist = summarizer.existing_summary
|
||||
|
||||
summarizer.force_summarize(Discourse.system_user) if gist.blank? || gist.outdated
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class StreamDiscordReply < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args)
|
||||
interaction = args[:interaction]
|
||||
|
||||
if SiteSetting.ai_discord_search_mode == "persona"
|
||||
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
|
||||
else
|
||||
DiscourseAi::Discord::Bot::Search.new(interaction).handle_interaction!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ module Jobs
|
||||
return unless topic = Topic.find_by(id: args[:topic_id])
|
||||
return unless user = User.find_by(id: args[:user_id])
|
||||
|
||||
strategy = DiscourseAi::Summarization.default_strategy
|
||||
strategy = DiscourseAi::Summarization.topic_summary(topic)
|
||||
return if strategy.nil? || !Guardian.new(user).can_see_summary?(topic)
|
||||
|
||||
guardian = Guardian.new(user)
|
||||
@@ -21,12 +21,12 @@ module Jobs
|
||||
|
||||
summary =
|
||||
DiscourseAi::TopicSummarization
|
||||
.new(strategy, topic, user)
|
||||
.new(strategy, user)
|
||||
.summarize(skip_age_check: skip_age_check) do |partial_summary|
|
||||
streamed_summary << partial_summary
|
||||
|
||||
# Throttle updates.
|
||||
if (Time.now - start > 0.5) || Rails.env.test?
|
||||
if (Time.now - start > 0.3) || Rails.env.test?
|
||||
payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } }
|
||||
|
||||
publish_update(topic, user, payload)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class UpdateAiBotPmTitle < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
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 post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
|
||||
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
|
||||
|
||||
DiscourseAi::AiBot::Playground.new(bot).title_playground(post)
|
||||
|
||||
publish_update(post.topic, { title: post.topic.title })
|
||||
end
|
||||
|
||||
def publish_update(topic, payload)
|
||||
allowed_users = topic.topic_allowed_users.pluck(:user_id)
|
||||
MessageBus.publish("/discourse-ai/ai-bot/topic/#{topic.id}", payload, user_ids: allowed_users)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class UpdateHotTopicGist < ::Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args)
|
||||
return if !SiteSetting.discourse_ai_enabled
|
||||
return if !SiteSetting.ai_summarization_enabled
|
||||
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
|
||||
|
||||
topic = Topic.find_by(id: args[:topic_id])
|
||||
return if topic.blank?
|
||||
|
||||
return if !TopicHotScore.where(topic: topic).exists?
|
||||
|
||||
summarizer = DiscourseAi::Summarization.topic_gist(topic)
|
||||
gist = summarizer.existing_summary
|
||||
return if gist.blank?
|
||||
return if !gist.outdated
|
||||
|
||||
summarizer.force_summarize(Discourse.system_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -35,8 +35,6 @@ module Jobs
|
||||
|
||||
rebaked += populate_topic_embeddings(vector_rep, topics)
|
||||
|
||||
vector_rep.consider_indexing
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
# Then, we'll try to backfill embeddings for topics that have outdated
|
||||
@@ -54,9 +52,12 @@ module Jobs
|
||||
# Finally, we'll try to backfill embeddings for topics that have outdated
|
||||
# embeddings due to edits or new replies. Here we only do 10% of the limit
|
||||
relation =
|
||||
topics.where("#{table_name}.updated_at < ?", 7.days.ago).limit((limit - rebaked) / 10)
|
||||
topics
|
||||
.where("#{table_name}.updated_at < ?", 6.hours.ago)
|
||||
.where("#{table_name}.updated_at < topics.updated_at")
|
||||
.limit((limit - rebaked) / 10)
|
||||
|
||||
populate_topic_embeddings(vector_rep, relation)
|
||||
populate_topic_embeddings(vector_rep, relation, force: true)
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
@@ -79,8 +80,6 @@ module Jobs
|
||||
rebaked += 1
|
||||
end
|
||||
|
||||
vector_rep.consider_indexing
|
||||
|
||||
return if rebaked >= limit
|
||||
|
||||
# Then, we'll try to backfill embeddings for posts that have outdated
|
||||
@@ -115,9 +114,12 @@ module Jobs
|
||||
|
||||
private
|
||||
|
||||
def populate_topic_embeddings(vector_rep, topics)
|
||||
def populate_topic_embeddings(vector_rep, topics, force: false)
|
||||
done = 0
|
||||
ids = topics.where("#{vector_rep.topic_table_name}.topic_id IS NULL").pluck("topics.id")
|
||||
|
||||
topics = topics.where("#{vector_rep.topic_table_name}.topic_id IS NULL") if !force
|
||||
|
||||
ids = topics.pluck("topics.id")
|
||||
|
||||
ids.each do |id|
|
||||
topic = Topic.find_by(id: id)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::Jobs
|
||||
class SummariesBackfill < ::Jobs::Scheduled
|
||||
every 5.minutes
|
||||
cluster_concurrency 1
|
||||
|
||||
def execute(_args)
|
||||
return if !SiteSetting.discourse_ai_enabled
|
||||
return if !SiteSetting.ai_summarization_enabled
|
||||
return if SiteSetting.ai_summary_backfill_maximum_topics_per_hour.zero?
|
||||
|
||||
# Split budget in 12 intervals, but make sure is at least one.
|
||||
limit_per_job = [SiteSetting.ai_summary_backfill_maximum_topics_per_hour, 12].max / 12
|
||||
budget = [current_budget, limit_per_job].min
|
||||
|
||||
backfill_candidates
|
||||
.limit(budget)
|
||||
.each do |topic|
|
||||
DiscourseAi::Summarization.topic_summary(topic).force_summarize(Discourse.system_user)
|
||||
end
|
||||
end
|
||||
|
||||
def backfill_candidates
|
||||
Topic
|
||||
.where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count)
|
||||
.joins(
|
||||
"LEFT OUTER JOIN ai_summaries ais ON topics.id = ais.target_id AND ais.target_type = 'Topic'",
|
||||
)
|
||||
.where(
|
||||
"ais.id IS NULL OR UPPER(ais.content_range) < topics.highest_post_number + 1",
|
||||
) # (1..1) gets stored ad (1..2).
|
||||
.order("ais.created_at DESC NULLS FIRST, topics.last_posted_at DESC")
|
||||
end
|
||||
|
||||
def current_budget
|
||||
base_budget = SiteSetting.ai_summary_backfill_maximum_topics_per_hour
|
||||
used_budget = AiSummary.complete.system.where("created_at > ?", 1.hour.ago).count
|
||||
|
||||
base_budget - used_budget
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,6 +12,7 @@ class AiApiAuditLog < ActiveRecord::Base
|
||||
Vllm = 5
|
||||
Cohere = 6
|
||||
Ollama = 7
|
||||
SambaNova = 8
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,4 +33,4 @@ end
|
||||
# post_id :integer
|
||||
# feature_name :string(255)
|
||||
# language_model :string(255)
|
||||
#
|
||||
# feature_context :jsonb
|
||||
|
||||
+91
-67
@@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiPersona < ActiveRecord::Base
|
||||
# TODO remove this line 01-11-2024
|
||||
self.ignored_columns = [:commands]
|
||||
# TODO remove this line 01-1-2025
|
||||
self.ignored_columns = %i[commands allow_chat mentionable]
|
||||
|
||||
# places a hard limit, so per site we cache a maximum of 500 classes
|
||||
MAX_PERSONAS_PER_SITE = 500
|
||||
@@ -20,6 +20,8 @@ class AiPersona < ActiveRecord::Base
|
||||
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 }
|
||||
validates :forced_tool_count, numericality: { greater_than: -2, maximum: 100_000 }
|
||||
has_many :rag_document_fragments, dependent: :destroy, as: :target
|
||||
|
||||
belongs_to :created_by, class_name: "User"
|
||||
belongs_to :user
|
||||
@@ -27,12 +29,7 @@ class AiPersona < ActiveRecord::Base
|
||||
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
|
||||
|
||||
def self.persona_cache
|
||||
@@ -55,30 +52,47 @@ class AiPersona < ActiveRecord::Base
|
||||
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, allow_chat",
|
||||
)
|
||||
.map do |id, user_id, username, allowed_group_ids, default_llm, mentionable, allow_chat|
|
||||
.map do |persona|
|
||||
{
|
||||
id: id,
|
||||
user_id: user_id,
|
||||
username: username,
|
||||
allowed_group_ids: allowed_group_ids,
|
||||
default_llm: default_llm,
|
||||
mentionable: mentionable,
|
||||
allow_chat: allow_chat,
|
||||
id: persona.id,
|
||||
user_id: persona.user_id,
|
||||
username: persona.user.username_lower,
|
||||
allowed_group_ids: persona.allowed_group_ids,
|
||||
default_llm: persona.default_llm,
|
||||
force_default_llm: persona.force_default_llm,
|
||||
allow_chat_channel_mentions: persona.allow_chat_channel_mentions,
|
||||
allow_chat_direct_messages: persona.allow_chat_direct_messages,
|
||||
allow_topic_mentions: persona.allow_topic_mentions,
|
||||
allow_personal_messages: persona.allow_personal_messages,
|
||||
}
|
||||
end
|
||||
|
||||
if user
|
||||
persona_users.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
|
||||
persona_users.select { |persona_user| user.in_any_groups?(persona_user[:allowed_group_ids]) }
|
||||
else
|
||||
persona_users
|
||||
end
|
||||
end
|
||||
|
||||
def self.allowed_chat(user: nil)
|
||||
personas = persona_cache[:allowed_chat] ||= persona_users.select { |u| u[:allow_chat] }
|
||||
def self.allowed_modalities(
|
||||
user: nil,
|
||||
allow_chat_channel_mentions: false,
|
||||
allow_chat_direct_messages: false,
|
||||
allow_topic_mentions: false,
|
||||
allow_personal_messages: false
|
||||
)
|
||||
index =
|
||||
"modality-#{allow_chat_channel_mentions}-#{allow_chat_direct_messages}-#{allow_topic_mentions}-#{allow_personal_messages}"
|
||||
|
||||
personas =
|
||||
persona_cache[index.to_sym] ||= persona_users.select do |persona|
|
||||
next true if allow_chat_channel_mentions && persona[:allow_chat_channel_mentions]
|
||||
next true if allow_chat_direct_messages && persona[:allow_chat_direct_messages]
|
||||
next true if allow_topic_mentions && persona[:allow_topic_mentions]
|
||||
next true if allow_personal_messages && persona[:allow_personal_messages]
|
||||
false
|
||||
end
|
||||
|
||||
if user
|
||||
personas.select { |u| user.in_any_groups?(u[:allowed_group_ids]) }
|
||||
else
|
||||
@@ -86,18 +100,6 @@ class AiPersona < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
def self.mentionables(user: nil)
|
||||
all_mentionables =
|
||||
persona_cache[:mentionables] ||= persona_users.select do |mentionable|
|
||||
mentionable[:mentionable]
|
||||
end
|
||||
if user
|
||||
all_mentionables.select { |mentionable| user.in_any_groups?(mentionable[:allowed_group_ids]) }
|
||||
else
|
||||
all_mentionables
|
||||
end
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
|
||||
def bump_cache
|
||||
@@ -116,7 +118,11 @@ class AiPersona < ActiveRecord::Base
|
||||
vision_max_pixels
|
||||
rag_conversation_chunks
|
||||
question_consolidator_llm
|
||||
allow_chat
|
||||
allow_chat_channel_mentions
|
||||
allow_chat_direct_messages
|
||||
allow_topic_mentions
|
||||
allow_personal_messages
|
||||
force_default_llm
|
||||
name
|
||||
description
|
||||
allowed_group_ids
|
||||
@@ -131,6 +137,8 @@ class AiPersona < ActiveRecord::Base
|
||||
instance_attributes[attr] = value
|
||||
end
|
||||
|
||||
instance_attributes[:username] = user&.username_lower
|
||||
|
||||
if persona_class
|
||||
instance_attributes.each do |key, value|
|
||||
# description/name are localized
|
||||
@@ -140,17 +148,23 @@ class AiPersona < ActiveRecord::Base
|
||||
end
|
||||
|
||||
options = {}
|
||||
force_tool_use = []
|
||||
|
||||
tools =
|
||||
self.tools.filter_map do |element|
|
||||
klass = nil
|
||||
|
||||
if element.is_a?(String) && element.start_with?("custom-")
|
||||
custom_tool_id = element.split("-", 2).last.to_i
|
||||
element = [element] if element.is_a?(String)
|
||||
|
||||
inner_name, current_options, should_force_tool_use =
|
||||
element.is_a?(Array) ? element : [element, nil]
|
||||
|
||||
if inner_name.start_with?("custom-")
|
||||
custom_tool_id = inner_name.split("-", 2).last.to_i
|
||||
if AiTool.exists?(id: custom_tool_id, enabled: true)
|
||||
klass = DiscourseAi::AiBot::Tools::Custom.class_instance(custom_tool_id)
|
||||
end
|
||||
else
|
||||
inner_name, current_options = element.is_a?(Array) ? element : [element, nil]
|
||||
inner_name = inner_name.gsub("Tool", "")
|
||||
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
|
||||
|
||||
@@ -159,9 +173,10 @@ class AiPersona < ActiveRecord::Base
|
||||
options[klass] = current_options if current_options
|
||||
rescue StandardError
|
||||
end
|
||||
|
||||
klass
|
||||
end
|
||||
|
||||
force_tool_use << klass if should_force_tool_use
|
||||
klass
|
||||
end
|
||||
|
||||
ai_persona_id = self.id
|
||||
@@ -181,6 +196,8 @@ class AiPersona < ActiveRecord::Base
|
||||
end
|
||||
|
||||
define_method(:tools) { tools }
|
||||
define_method(:force_tool_use) { force_tool_use }
|
||||
define_method(:forced_tool_count) { @ai_persona&.forced_tool_count }
|
||||
define_method(:options) { options }
|
||||
define_method(:temperature) { @ai_persona&.temperature }
|
||||
define_method(:top_p) { @ai_persona&.top_p }
|
||||
@@ -230,14 +247,17 @@ class AiPersona < ActiveRecord::Base
|
||||
|
||||
def regenerate_rag_fragments
|
||||
if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed?
|
||||
RagDocumentFragment.where(ai_persona: self).delete_all
|
||||
RagDocumentFragment.where(target: self).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def chat_preconditions
|
||||
if allow_chat && !default_llm
|
||||
if (
|
||||
allow_chat_channel_mentions || allow_chat_direct_messages || allow_topic_mentions ||
|
||||
force_default_llm
|
||||
) && !default_llm
|
||||
errors.add(:default_llm, I18n.t("discourse_ai.ai_bot.personas.default_llm_required"))
|
||||
end
|
||||
end
|
||||
@@ -261,32 +281,36 @@ end
|
||||
#
|
||||
# Table name: ai_personas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) 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
|
||||
# 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
|
||||
# allow_chat :boolean default(FALSE), not null
|
||||
# tool_details :boolean default(TRUE), not null
|
||||
# tools :json not null
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) 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
|
||||
# default_llm :text
|
||||
# max_context_posts :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
|
||||
# tool_details :boolean default(TRUE), not null
|
||||
# tools :json not null
|
||||
# forced_tool_count :integer default(-1), not null
|
||||
# allow_chat_channel_mentions :boolean default(FALSE), not null
|
||||
# allow_chat_direct_messages :boolean default(FALSE), not null
|
||||
# allow_topic_mentions :boolean default(FALSE), not null
|
||||
# allow_personal_messages :boolean default(TRUE), not null
|
||||
# force_default_llm :boolean default(FALSE), not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -3,6 +3,27 @@
|
||||
class AiSummary < ActiveRecord::Base
|
||||
belongs_to :target, polymorphic: true
|
||||
|
||||
enum :summary_type, { complete: 0, gist: 1 }
|
||||
enum :origin, { human: 0, system: 1 }
|
||||
|
||||
def self.store!(strategy, llm_model, summary, og_content, human:)
|
||||
content_ids = og_content.map { |c| c[:id] }
|
||||
|
||||
AiSummary.create!(
|
||||
target: strategy.target,
|
||||
algorithm: llm_model.name,
|
||||
content_range: (content_ids.first..content_ids.last),
|
||||
summarized_text: summary,
|
||||
original_content_sha: build_sha(content_ids.join),
|
||||
summary_type: strategy.type,
|
||||
origin: !!human ? origins[:human] : origins[:system],
|
||||
)
|
||||
end
|
||||
|
||||
def self.build_sha(joined_ids)
|
||||
Digest::SHA256.hexdigest(joined_ids)
|
||||
end
|
||||
|
||||
def mark_as_outdated
|
||||
@outdated = true
|
||||
end
|
||||
@@ -25,6 +46,8 @@ end
|
||||
# algorithm :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# summary_type :integer default("complete"), not null
|
||||
# origin :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
+175
-10
@@ -7,6 +7,10 @@ class AiTool < ActiveRecord::Base
|
||||
validates :script, presence: true, length: { maximum: 100_000 }
|
||||
validates :created_by_id, presence: true
|
||||
belongs_to :created_by, class_name: "User"
|
||||
has_many :rag_document_fragments, dependent: :destroy, as: :target
|
||||
has_many :upload_references, as: :target, dependent: :destroy
|
||||
has_many :uploads, through: :upload_references
|
||||
before_update :regenerate_rag_fragments
|
||||
|
||||
def signature
|
||||
{ name: name, description: description, parameters: parameters.map(&:symbolize_keys) }
|
||||
@@ -28,6 +32,97 @@ class AiTool < ActiveRecord::Base
|
||||
AiPersona.persona_cache.flush!
|
||||
end
|
||||
|
||||
def regenerate_rag_fragments
|
||||
if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed?
|
||||
RagDocumentFragment.where(target: self).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
def self.preamble
|
||||
<<~JS
|
||||
/**
|
||||
* Tool API Quick Reference
|
||||
*
|
||||
* Entry Functions
|
||||
*
|
||||
* invoke(parameters): Main function. Receives parameters (Object). Must return a JSON-serializable value.
|
||||
* Example:
|
||||
* function invoke(parameters) { return "result"; }
|
||||
*
|
||||
* details(): Optional. Returns a string describing the tool.
|
||||
* Example:
|
||||
* function details() { return "Tool description."; }
|
||||
*
|
||||
* Provided Objects
|
||||
*
|
||||
* 1. http
|
||||
* http.get(url, options?): Performs an HTTP GET request.
|
||||
* Parameters:
|
||||
* url (string): The request URL.
|
||||
* options (Object, optional):
|
||||
* headers (Object): Request headers.
|
||||
* Returns:
|
||||
* { status: number, body: string }
|
||||
*
|
||||
* http.post(url, options?): Performs an HTTP POST request.
|
||||
* Parameters:
|
||||
* url (string): The request URL.
|
||||
* options (Object, optional):
|
||||
* headers (Object): Request headers.
|
||||
* body (string): Request body.
|
||||
* Returns:
|
||||
* { status: number, body: string }
|
||||
*
|
||||
* (also available: http.put, http.patch, http.delete)
|
||||
*
|
||||
* Note: Max 20 HTTP requests per execution.
|
||||
*
|
||||
* 2. llm
|
||||
* llm.truncate(text, length): Truncates text to a specified token length.
|
||||
* Parameters:
|
||||
* text (string): Text to truncate.
|
||||
* length (number): Max tokens.
|
||||
* Returns:
|
||||
* Truncated string.
|
||||
*
|
||||
* 3. index
|
||||
* index.search(query, options?): Searches indexed documents.
|
||||
* Parameters:
|
||||
* query (string): Search query.
|
||||
* options (Object, optional):
|
||||
* filenames (Array): Limit search to specific files.
|
||||
* limit (number): Max fragments (up to 200).
|
||||
* Returns:
|
||||
* Array of { fragment: string, metadata: string }
|
||||
*
|
||||
* 4. upload
|
||||
* upload.create(filename, base_64_content): Uploads a file.
|
||||
* Parameters:
|
||||
* filename (string): Name of the file.
|
||||
* base_64_content (string): Base64 encoded file content.
|
||||
* Returns:
|
||||
* { id: number, short_url: string }
|
||||
*
|
||||
* 5. chain
|
||||
* chain.setCustomRaw(raw): Sets the body of the post and exist chain.
|
||||
* Parameters:
|
||||
* raw (string): raw content to add to post.
|
||||
*
|
||||
* Constraints
|
||||
*
|
||||
* Execution Time: ≤ 2000ms
|
||||
* Memory: ≤ 10MB
|
||||
* HTTP Requests: ≤ 20 per execution
|
||||
* Exceeding limits will result in errors or termination.
|
||||
*
|
||||
* Security
|
||||
*
|
||||
* Sandboxed Environment: No access to system or global objects.
|
||||
* No File System Access: Cannot read or write files.
|
||||
*/
|
||||
JS
|
||||
end
|
||||
|
||||
def self.presets
|
||||
[
|
||||
{
|
||||
@@ -38,6 +133,7 @@ class AiTool < ActiveRecord::Base
|
||||
{ name: "url", type: "string", required: true, description: "The URL to browse" },
|
||||
],
|
||||
script: <<~SCRIPT,
|
||||
#{preamble}
|
||||
let url;
|
||||
function invoke(p) {
|
||||
url = p.url;
|
||||
@@ -70,6 +166,7 @@ class AiTool < ActiveRecord::Base
|
||||
{ name: "amount", type: "number", description: "Amount to convert eg: 123.45" },
|
||||
],
|
||||
script: <<~SCRIPT,
|
||||
#{preamble}
|
||||
// note: this script uses the open.er-api.com service, it is only updated
|
||||
// once every 24 hours, for more up to date rates see: https://www.exchangerate-api.com
|
||||
function invoke(params) {
|
||||
@@ -118,6 +215,7 @@ class AiTool < ActiveRecord::Base
|
||||
},
|
||||
],
|
||||
script: <<~SCRIPT,
|
||||
#{preamble}
|
||||
function invoke(params) {
|
||||
const apiKey = 'YOUR_ALPHAVANTAGE_API_KEY'; // Replace with your actual API key
|
||||
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${params.symbol}&apikey=${apiKey}`;
|
||||
@@ -153,7 +251,72 @@ class AiTool < ActiveRecord::Base
|
||||
SCRIPT
|
||||
summary: "Get real-time stock quotes using AlphaVantage API",
|
||||
},
|
||||
{
|
||||
preset_id: "image_generation",
|
||||
name: "image_generation",
|
||||
description:
|
||||
"Generate images using the FLUX model from Black Forest Labs using together.ai",
|
||||
parameters: [
|
||||
{
|
||||
name: "prompt",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "The text prompt for image generation",
|
||||
},
|
||||
{
|
||||
name: "seed",
|
||||
type: "number",
|
||||
required: false,
|
||||
description: "Optional seed for random number generation",
|
||||
},
|
||||
],
|
||||
script: <<~SCRIPT,
|
||||
#{preamble}
|
||||
const apiKey = "YOUR_KEY";
|
||||
const model = "black-forest-labs/FLUX.1.1-pro";
|
||||
|
||||
function invoke(params) {
|
||||
let seed = parseInt(params.seed);
|
||||
if (!(seed > 0)) {
|
||||
seed = Math.floor(Math.random() * 1000000) + 1;
|
||||
}
|
||||
|
||||
const prompt = params.prompt;
|
||||
const body = {
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
steps: 10,
|
||||
n: 1,
|
||||
seed: seed,
|
||||
response_format: "b64_json",
|
||||
};
|
||||
|
||||
const result = http.post("https://api.together.xyz/v1/images/generations", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const base64Image = JSON.parse(result.body).data[0].b64_json;
|
||||
const image = upload.create("generated_image.png", base64Image);
|
||||
const raw = `\n\n`;
|
||||
chain.setCustomRaw(raw);
|
||||
|
||||
return { result: "Image generated successfully", seed: seed };
|
||||
}
|
||||
|
||||
function details() {
|
||||
return "Generates images based on a text prompt using the FLUX model.";
|
||||
}
|
||||
SCRIPT
|
||||
summary: "Generate image",
|
||||
},
|
||||
{ preset_id: "empty_tool", script: <<~SCRIPT },
|
||||
#{preamble}
|
||||
function invoke(params) {
|
||||
// logic here
|
||||
return params;
|
||||
@@ -173,14 +336,16 @@ end
|
||||
#
|
||||
# Table name: ai_tools
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string not null
|
||||
# description :string not null
|
||||
# summary :string not null
|
||||
# parameters :jsonb not null
|
||||
# script :text not null
|
||||
# created_by_id :integer not null
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint not null, primary key
|
||||
# name :string not null
|
||||
# description :string not null
|
||||
# summary :string not null
|
||||
# parameters :jsonb not null
|
||||
# script :text not null
|
||||
# created_by_id :integer not null
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# rag_chunk_tokens :integer default(374), not null
|
||||
# rag_chunk_overlap_tokens :integer default(10), not null
|
||||
#
|
||||
|
||||
@@ -11,7 +11,7 @@ end
|
||||
# id :bigint not null, primary key
|
||||
# model_used :string
|
||||
# classification_type :string
|
||||
# target_id :integer
|
||||
# target_id :bigint
|
||||
# target_type :string
|
||||
# classification :jsonb
|
||||
# created_at :datetime not null
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user