Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1598b4679 | ||
|
|
65bbcd71fc | ||
|
|
d07cf51653 | ||
|
|
20612fde52 | ||
|
|
7e9c0dc076 | ||
|
|
2d1ce01320 | ||
|
|
b24669c810 | ||
|
|
24b69bf840 | ||
|
|
404092a68c | ||
|
|
327adbde29 | ||
|
|
749af40fad | ||
|
|
61758ff8a6 | ||
|
|
b6cefd10fa | ||
|
|
11d0f60f1e | ||
|
|
f9f89adac5 | ||
|
|
b480f13a0f | ||
|
|
47ecf86aa1 | ||
|
|
792df58fbc | ||
|
|
ceac6e5efb | ||
|
|
bdb8f1d5e0 | ||
|
|
d15876025f | ||
|
|
7607477ff9 | ||
|
|
059b3fabb8 | ||
|
|
6a7a45fd4f | ||
|
|
ac705b694b | ||
|
|
a4033e2af9 | ||
|
|
f35db8068b | ||
|
|
fae2d5ff2c | ||
|
|
90ce942108 | ||
|
|
24b107881a | ||
|
|
534b0df391 | ||
|
|
222e2cf4f9 | ||
|
|
94b85ece80 | ||
|
|
1c40a698ca | ||
|
|
fae1fbc796 | ||
|
|
eae527f99d | ||
|
|
97ec2c5ff4 | ||
|
|
04c4ff8cf0 | ||
|
|
47c1ea337e | ||
|
|
47f5da7e42 | ||
|
|
ae80494448 | ||
|
|
a4440c507b | ||
|
|
5fc7a730ef | ||
|
|
34f43f398d | ||
|
|
6da35d8e66 | ||
|
|
700e9de073 | ||
|
|
375dd702b2 | ||
|
|
7ca21cc329 | ||
|
|
51e0a96e51 | ||
|
|
6ce14a778b | ||
|
|
085dde7042 | ||
|
|
7ebbcd2de3 | ||
|
|
a55216773a | ||
|
|
5e87a50202 | ||
|
|
70fae2b699 | ||
|
|
5ba274f7a5 | ||
|
|
4ba74511c2 | ||
|
|
50f61925eb | ||
|
|
625fd06744 | ||
|
|
b32b1cf241 | ||
|
|
d6beac48f8 | ||
|
|
938d4c018c | ||
|
|
e7c2cd861a | ||
|
|
f8a81a0beb | ||
|
|
8203bdfbc9 | ||
|
|
ce6a2eca21 | ||
|
|
7c65dd171f | ||
|
|
e3f5e86dc5 | ||
|
|
fcf2350652 | ||
|
|
fc88bb08ab | ||
|
|
117c06220e | ||
|
|
0ac18d157b | ||
|
|
50ad5415ff | ||
|
|
c53363406d | ||
|
|
3828370679 |
@@ -1,3 +1,4 @@
|
|||||||
|
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
|
||||||
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
|
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
|
||||||
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
|
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
|
||||||
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
|
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
export default class AdminPluginsShowDiscourseAiLlmsShow extends DiscourseRoute {
|
export default class AdminPluginsShowDiscourseAiLlmsEdit extends DiscourseRoute {
|
||||||
async model(params) {
|
async model(params) {
|
||||||
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
|
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
|
||||||
const id = parseInt(params.id, 10);
|
const id = parseInt(params.id, 10);
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
export default class AdminPluginsShowDiscourseAiPersonasShow extends DiscourseRoute {
|
export default class AdminPluginsShowDiscourseAiPersonasEdit extends DiscourseRoute {
|
||||||
async model(params) {
|
async model(params) {
|
||||||
const allPersonas = this.modelFor(
|
const allPersonas = this.modelFor(
|
||||||
"adminPlugins.show.discourse-ai-personas"
|
"adminPlugins.show.discourse-ai-personas"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { service } from "@ember/service";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
|
export default class DiscourseAiSpamRoute extends DiscourseRoute {
|
||||||
|
@service store;
|
||||||
|
|
||||||
|
model() {
|
||||||
|
return ajax("/admin/plugins/discourse-ai/ai-spam.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
export default class DiscourseAiToolsShowRoute extends DiscourseRoute {
|
export default class DiscourseAiToolsEditRoute extends DiscourseRoute {
|
||||||
async model(params) {
|
async model(params) {
|
||||||
const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools");
|
const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools");
|
||||||
const id = parseInt(params.id, 10);
|
const id = parseInt(params.id, 10);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<AiSpam @model={{this.model}} />
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module Admin
|
||||||
|
class AiLlmQuotasController < ::Admin::AdminController
|
||||||
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
|
def index
|
||||||
|
quotas = LlmQuota.includes(:group)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
quotas:
|
||||||
|
ActiveModel::ArraySerializer.new(quotas, each_serializer: LlmQuotaSerializer),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
quota = LlmQuota.new(quota_params)
|
||||||
|
|
||||||
|
if quota.save
|
||||||
|
render json: LlmQuotaSerializer.new(quota), status: :created
|
||||||
|
else
|
||||||
|
render_json_error quota
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
quota = LlmQuota.find(params[:id])
|
||||||
|
|
||||||
|
if quota.update(quota_params)
|
||||||
|
render json: LlmQuotaSerializer.new(quota)
|
||||||
|
else
|
||||||
|
render_json_error quota
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
quota = LlmQuota.find(params[:id])
|
||||||
|
quota.destroy!
|
||||||
|
|
||||||
|
head :no_content
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: I18n.t("not_found") }, status: 404
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def quota_params
|
||||||
|
params.require(:quota).permit(
|
||||||
|
:group_id,
|
||||||
|
:llm_model_id,
|
||||||
|
:max_tokens,
|
||||||
|
:max_usages,
|
||||||
|
:duration_seconds,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,7 +6,7 @@ module DiscourseAi
|
|||||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
def index
|
def index
|
||||||
llms = LlmModel.all.order(:display_name)
|
llms = LlmModel.all.includes(:llm_quotas).order(:display_name)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
ai_llms:
|
ai_llms:
|
||||||
@@ -30,13 +30,21 @@ module DiscourseAi
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
llm_model = LlmModel.find(params[:id])
|
llm_model = LlmModel.find(params[:id])
|
||||||
render json: LlmModelSerializer.new(llm_model)
|
render json: LlmModelSerializer.new(llm_model)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
llm_model = LlmModel.new(ai_llm_params)
|
llm_model = LlmModel.new(ai_llm_params)
|
||||||
|
|
||||||
|
# we could do nested attributes but the mechanics are not ideal leading
|
||||||
|
# to lots of complex debugging, this is simpler
|
||||||
|
quota_params.each { |quota| llm_model.llm_quotas.build(quota) } if quota_params
|
||||||
|
|
||||||
if llm_model.save
|
if llm_model.save
|
||||||
llm_model.toggle_companion_user
|
llm_model.toggle_companion_user
|
||||||
render json: LlmModelSerializer.new(llm_model), status: :created
|
render json: LlmModelSerializer.new(llm_model), status: :created
|
||||||
@@ -48,6 +56,25 @@ module DiscourseAi
|
|||||||
def update
|
def update
|
||||||
llm_model = LlmModel.find(params[:id])
|
llm_model = LlmModel.find(params[:id])
|
||||||
|
|
||||||
|
if params[:ai_llm].key?(:llm_quotas)
|
||||||
|
if quota_params
|
||||||
|
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
|
||||||
|
new_quota_group_ids = quota_params.map { |q| q[:group_id] }
|
||||||
|
|
||||||
|
llm_model
|
||||||
|
.llm_quotas
|
||||||
|
.where(group_id: existing_quota_group_ids - new_quota_group_ids)
|
||||||
|
.destroy_all
|
||||||
|
|
||||||
|
quota_params.each do |quota_param|
|
||||||
|
quota = llm_model.llm_quotas.find_or_initialize_by(group_id: quota_param[:group_id])
|
||||||
|
quota.update!(quota_param)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
llm_model.llm_quotas.destroy_all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if llm_model.seeded?
|
if llm_model.seeded?
|
||||||
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
|
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
|
||||||
end
|
end
|
||||||
@@ -107,6 +134,19 @@ module DiscourseAi
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def quota_params
|
||||||
|
if params[:ai_llm][:llm_quotas].present?
|
||||||
|
params[:ai_llm][:llm_quotas].map do |quota|
|
||||||
|
mapped = {}
|
||||||
|
mapped[:group_id] = quota[:group_id].to_i
|
||||||
|
mapped[:max_tokens] = quota[:max_tokens].to_i if quota[:max_tokens].present?
|
||||||
|
mapped[:max_usages] = quota[:max_usages].to_i if quota[:max_usages].present?
|
||||||
|
mapped[:duration_seconds] = quota[:duration_seconds].to_i
|
||||||
|
mapped
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def ai_llm_params(updating: nil)
|
def ai_llm_params(updating: nil)
|
||||||
return {} if params[:ai_llm].blank?
|
return {} if params[:ai_llm].blank?
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module DiscourseAi
|
|||||||
class AiPersonasController < ::Admin::AdminController
|
class AiPersonasController < ::Admin::AdminController
|
||||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
before_action :find_ai_persona, only: %i[show update destroy create_user]
|
before_action :find_ai_persona, only: %i[edit update destroy create_user]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
ai_personas =
|
ai_personas =
|
||||||
@@ -33,7 +33,10 @@ module DiscourseAi
|
|||||||
render json: { ai_personas: ai_personas, meta: { tools: tools, llms: llms } }
|
render json: { ai_personas: ai_personas, meta: { tools: tools, llms: llms } }
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
|
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module Admin
|
||||||
|
class AiSpamController < ::Admin::AdminController
|
||||||
|
requires_plugin "discourse-ai"
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: AiSpamSerializer.new(spam_config, root: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
updated_params = {}
|
||||||
|
if allowed_params.key?(:llm_model_id)
|
||||||
|
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
|
||||||
|
if llm_model_id.to_i < 0 &&
|
||||||
|
!SiteSetting.ai_spam_detection_model_allowed_seeded_models_map.include?(
|
||||||
|
llm_model_id.to_s,
|
||||||
|
)
|
||||||
|
return(
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"),
|
||||||
|
status: 422,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
updated_params[:data] = {
|
||||||
|
custom_instructions: allowed_params[:custom_instructions],
|
||||||
|
} if allowed_params.key?(:custom_instructions)
|
||||||
|
|
||||||
|
if updated_params.present?
|
||||||
|
# not using upsert cause we will not get the correct validation errors
|
||||||
|
if AiModerationSetting.spam
|
||||||
|
AiModerationSetting.spam.update!(updated_params)
|
||||||
|
else
|
||||||
|
AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
|
||||||
|
|
||||||
|
if allowed_params.key?(:is_enabled)
|
||||||
|
if is_enabled && !AiModerationSetting.spam&.llm_model_id
|
||||||
|
return(
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("discourse_ai.llm.configuration.must_select_model"),
|
||||||
|
status: 422,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
SiteSetting.ai_spam_detection_enabled = is_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: AiSpamSerializer.new(spam_config, root: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test
|
||||||
|
url = params[:post_url].to_s
|
||||||
|
post = nil
|
||||||
|
|
||||||
|
if url.match?(/^\d+$/)
|
||||||
|
post_id = url.to_i
|
||||||
|
post = Post.find_by(id: post_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
route = UrlHelper.rails_route_from_url(url) if !post
|
||||||
|
|
||||||
|
if route
|
||||||
|
if route[:controller] == "topics"
|
||||||
|
post_number = route[:post_number] || 1
|
||||||
|
post = Post.with_deleted.find_by(post_number: post_number, topic_id: route[:topic_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
raise Discourse::NotFound if !post
|
||||||
|
|
||||||
|
result =
|
||||||
|
DiscourseAi::AiModeration::SpamScanner.test_post(
|
||||||
|
post,
|
||||||
|
custom_instructions: params[:custom_instructions],
|
||||||
|
llm_id: params[:llm_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: result
|
||||||
|
end
|
||||||
|
|
||||||
|
def fix_errors
|
||||||
|
case params[:error]
|
||||||
|
when "spam_scanner_not_admin"
|
||||||
|
begin
|
||||||
|
DiscourseAi::AiModeration::SpamScanner.fix_spam_scanner_not_admin
|
||||||
|
render json: success_json
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("discourse_ai.spam_detection.bot_user_update_failed"),
|
||||||
|
status: :unprocessable_entity,
|
||||||
|
)
|
||||||
|
rescue StandardError
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("discourse_ai.spam_detection.unexpected"),
|
||||||
|
status: :internal_server_error,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("discourse_ai.spam_detection.invalid_error_type"),
|
||||||
|
status: :bad_request,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def allowed_params
|
||||||
|
params.permit(:is_enabled, :llm_model_id, :custom_instructions)
|
||||||
|
end
|
||||||
|
|
||||||
|
def spam_config
|
||||||
|
spam_config = {
|
||||||
|
enabled: SiteSetting.ai_spam_detection_enabled,
|
||||||
|
settings: AiModerationSetting.spam,
|
||||||
|
}
|
||||||
|
|
||||||
|
spam_config[:stats] = DiscourseAi::AiModeration::SpamReport.generate(min_date: 1.week.ago)
|
||||||
|
|
||||||
|
if spam_config[:stats].scanned_count > 0
|
||||||
|
spam_config[
|
||||||
|
:flagging_username
|
||||||
|
] = DiscourseAi::AiModeration::SpamScanner.flagging_user&.username
|
||||||
|
end
|
||||||
|
spam_config
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,14 +5,17 @@ module DiscourseAi
|
|||||||
class AiToolsController < ::Admin::AdminController
|
class AiToolsController < ::Admin::AdminController
|
||||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
before_action :find_ai_tool, only: %i[test show update destroy]
|
before_action :find_ai_tool, only: %i[test edit update destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
ai_tools = AiTool.all
|
ai_tools = AiTool.all
|
||||||
render_serialized({ ai_tools: ai_tools }, AiCustomToolListSerializer, root: false)
|
render_serialized({ ai_tools: ai_tools }, AiCustomToolListSerializer, root: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
render_serialized(@ai_tool, AiCustomToolSerializer)
|
render_serialized(@ai_tool, AiCustomToolSerializer)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ module DiscourseAi
|
|||||||
requires_plugin "discourse-ai"
|
requires_plugin "discourse-ai"
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def report
|
||||||
render json: AiUsageSerializer.new(create_report, root: false)
|
render json: AiUsageSerializer.new(create_report, root: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -19,22 +19,33 @@ module DiscourseAi
|
|||||||
raise Discourse::NotFound if !guardian.can_see?(post)
|
raise Discourse::NotFound if !guardian.can_see?(post)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
name = artifact.name
|
||||||
|
|
||||||
|
if params[:version].present?
|
||||||
|
artifact = artifact.versions.find_by(version_number: params[:version])
|
||||||
|
raise Discourse::NotFound if !artifact
|
||||||
|
end
|
||||||
|
|
||||||
|
js = artifact.js || ""
|
||||||
|
if !js.match?(%r{\A\s*<script.*</script>}mi)
|
||||||
|
mod = ""
|
||||||
|
mod = " type=\"module\"" if js.match?(/\A\s*import.*/)
|
||||||
|
js = "<script#{mod}>\n#{js}\n</script>"
|
||||||
|
end
|
||||||
# Prepare the inner (untrusted) HTML document
|
# Prepare the inner (untrusted) HTML document
|
||||||
untrusted_html = <<~HTML
|
untrusted_html = <<~HTML
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>#{ERB::Util.html_escape(artifact.name)}</title>
|
<title>#{ERB::Util.html_escape(name)}</title>
|
||||||
<style>
|
<style>
|
||||||
#{artifact.css}
|
#{artifact.css}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
#{artifact.html}
|
#{artifact.html}
|
||||||
<script>
|
#{js}
|
||||||
#{artifact.js}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
HTML
|
HTML
|
||||||
@@ -45,7 +56,8 @@ module DiscourseAi
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>#{ERB::Util.html_escape(artifact.name)}</title>
|
<title>#{ERB::Util.html_escape(name)}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover, interactive-widget=resizes-content">
|
||||||
<style>
|
<style>
|
||||||
html, body, iframe {
|
html, body, iframe {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -67,7 +79,9 @@ module DiscourseAi
|
|||||||
HTML
|
HTML
|
||||||
|
|
||||||
response.headers.delete("X-Frame-Options")
|
response.headers.delete("X-Frame-Options")
|
||||||
response.headers["Content-Security-Policy"] = "script-src 'unsafe-inline';"
|
response.headers[
|
||||||
|
"Content-Security-Policy"
|
||||||
|
] = "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com https://ajax.googleapis.com https://cdn.jsdelivr.net;"
|
||||||
response.headers["X-Robots-Tag"] = "noindex"
|
response.headers["X-Robots-Tag"] = "noindex"
|
||||||
|
|
||||||
# Render the content
|
# Render the content
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class AiSpamScan < ::Jobs::Base
|
||||||
|
def execute(args)
|
||||||
|
return if !args[:post_id]
|
||||||
|
post = Post.find_by(id: args[:post_id])
|
||||||
|
return if !post
|
||||||
|
|
||||||
|
DiscourseAi::AiModeration::SpamScanner.perform_scan(post)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,9 +18,7 @@ module ::Jobs
|
|||||||
target = target_type.constantize.find_by(id: target_id)
|
target = target_type.constantize.find_by(id: target_id)
|
||||||
return if !target
|
return if !target
|
||||||
|
|
||||||
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
|
vector_rep = DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation
|
||||||
vector_rep =
|
|
||||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
|
||||||
|
|
||||||
tokenizer = vector_rep.tokenizer
|
tokenizer = vector_rep.tokenizer
|
||||||
chunk_tokens = target.rag_chunk_tokens
|
chunk_tokens = target.rag_chunk_tokens
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Jobs
|
|
||||||
class EvaluatePostUploads < ::Jobs::Base
|
|
||||||
def execute(args)
|
|
||||||
return unless SiteSetting.ai_nsfw_detection_enabled
|
|
||||||
return if (post_id = args[:post_id]).blank?
|
|
||||||
|
|
||||||
post = Post.includes(:uploads).find_by_id(post_id)
|
|
||||||
return if post.nil? || post.uploads.empty?
|
|
||||||
|
|
||||||
return if post.uploads.none? { |u| FileHelper.is_supported_image?(u.url) }
|
|
||||||
|
|
||||||
DiscourseAi::PostClassificator.new(DiscourseAi::Nsfw::Classification.new).classify!(post)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -14,7 +14,7 @@ module ::Jobs
|
|||||||
|
|
||||||
summarizer = DiscourseAi::Summarization.topic_gist(topic)
|
summarizer = DiscourseAi::Summarization.topic_gist(topic)
|
||||||
gist = summarizer.existing_summary
|
gist = summarizer.existing_summary
|
||||||
return if gist.present? && !gist.outdated
|
return if gist.present? && (!gist.outdated || gist.created_at >= 5.minutes.ago)
|
||||||
|
|
||||||
summarizer.force_summarize(Discourse.system_user)
|
summarizer.force_summarize(Discourse.system_user)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ module Jobs
|
|||||||
return if topic.private_message? && !SiteSetting.ai_embeddings_generate_for_pms
|
return if topic.private_message? && !SiteSetting.ai_embeddings_generate_for_pms
|
||||||
return if post.raw.blank?
|
return if post.raw.blank?
|
||||||
|
|
||||||
strategy = DiscourseAi::Embeddings::Strategies::Truncation.new
|
DiscourseAi::Embeddings::Vector.instance.generate_representation_from(target)
|
||||||
vector_rep =
|
|
||||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(strategy)
|
|
||||||
|
|
||||||
vector_rep.generate_representation_from(target)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ module ::Jobs
|
|||||||
def execute(args)
|
def execute(args)
|
||||||
return if (fragments = RagDocumentFragment.where(id: args[:fragment_ids].to_a)).empty?
|
return if (fragments = RagDocumentFragment.where(id: args[:fragment_ids].to_a)).empty?
|
||||||
|
|
||||||
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
|
vector = DiscourseAi::Embeddings::Vector.instance
|
||||||
vector_rep =
|
|
||||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
|
|
||||||
|
|
||||||
# generate_representation_from checks compares the digest value to make sure
|
# generate_representation_from checks compares the digest value to make sure
|
||||||
# the embedding is only generated once per fragment unless something changes.
|
# the embedding is only generated once per fragment unless something changes.
|
||||||
fragments.map { |fragment| vector_rep.generate_representation_from(fragment) }
|
fragments.map { |fragment| vector.generate_representation_from(fragment) }
|
||||||
|
|
||||||
last_fragment = fragments.last
|
last_fragment = fragments.last
|
||||||
target = last_fragment.target
|
target = last_fragment.target
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module ::Jobs
|
|
||||||
class ToxicityClassifyChatMessage < ::Jobs::Base
|
|
||||||
def execute(args)
|
|
||||||
return unless SiteSetting.ai_toxicity_enabled
|
|
||||||
|
|
||||||
return if (chat_message_id = args[:chat_message_id]).blank?
|
|
||||||
|
|
||||||
chat_message = ::Chat::Message.find_by(id: chat_message_id)
|
|
||||||
return if chat_message&.message.blank?
|
|
||||||
|
|
||||||
DiscourseAi::ChatMessageClassificator.new(
|
|
||||||
DiscourseAi::Toxicity::ToxicityClassification.new,
|
|
||||||
).classify!(chat_message)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module ::Jobs
|
|
||||||
class ToxicityClassifyPost < ::Jobs::Base
|
|
||||||
def execute(args)
|
|
||||||
return unless SiteSetting.ai_toxicity_enabled
|
|
||||||
|
|
||||||
post_id = args[:post_id]
|
|
||||||
return if post_id.blank?
|
|
||||||
|
|
||||||
post = Post.find_by(id: post_id, post_type: Post.types[:regular])
|
|
||||||
return if post&.raw.blank?
|
|
||||||
|
|
||||||
DiscourseAi::PostClassificator.new(
|
|
||||||
DiscourseAi::Toxicity::ToxicityClassification.new,
|
|
||||||
).classify!(post)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -20,10 +20,9 @@ module Jobs
|
|||||||
|
|
||||||
rebaked = 0
|
rebaked = 0
|
||||||
|
|
||||||
strategy = DiscourseAi::Embeddings::Strategies::Truncation.new
|
vector = DiscourseAi::Embeddings::Vector.instance
|
||||||
vector_rep =
|
vector_def = vector.vdef
|
||||||
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(strategy)
|
table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE
|
||||||
table_name = vector_rep.topic_table_name
|
|
||||||
|
|
||||||
topics =
|
topics =
|
||||||
Topic
|
Topic
|
||||||
@@ -31,21 +30,20 @@ module Jobs
|
|||||||
.where(archetype: Archetype.default)
|
.where(archetype: Archetype.default)
|
||||||
.where(deleted_at: nil)
|
.where(deleted_at: nil)
|
||||||
.order("topics.bumped_at DESC")
|
.order("topics.bumped_at DESC")
|
||||||
.limit(limit - rebaked)
|
|
||||||
|
|
||||||
rebaked += populate_topic_embeddings(vector_rep, topics)
|
rebaked += populate_topic_embeddings(vector, topics.limit(limit - rebaked))
|
||||||
|
|
||||||
return if rebaked >= limit
|
return if rebaked >= limit
|
||||||
|
|
||||||
# Then, we'll try to backfill embeddings for topics that have outdated
|
# Then, we'll try to backfill embeddings for topics that have outdated
|
||||||
# embeddings, be it model or strategy version
|
# embeddings, be it model or strategy version
|
||||||
relation = topics.where(<<~SQL)
|
relation = topics.where(<<~SQL).limit(limit - rebaked)
|
||||||
#{table_name}.model_version < #{vector_rep.version}
|
#{table_name}.model_version < #{vector_def.version}
|
||||||
OR
|
OR
|
||||||
#{table_name}.strategy_version < #{strategy.version}
|
#{table_name}.strategy_version < #{vector_def.strategy_version}
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
rebaked += populate_topic_embeddings(vector_rep, relation)
|
rebaked += populate_topic_embeddings(vector, relation)
|
||||||
|
|
||||||
return if rebaked >= limit
|
return if rebaked >= limit
|
||||||
|
|
||||||
@@ -57,28 +55,30 @@ module Jobs
|
|||||||
.where("#{table_name}.updated_at < topics.updated_at")
|
.where("#{table_name}.updated_at < topics.updated_at")
|
||||||
.limit((limit - rebaked) / 10)
|
.limit((limit - rebaked) / 10)
|
||||||
|
|
||||||
populate_topic_embeddings(vector_rep, relation, force: true)
|
populate_topic_embeddings(vector, relation, force: true)
|
||||||
|
|
||||||
return if rebaked >= limit
|
return if rebaked >= limit
|
||||||
|
|
||||||
return unless SiteSetting.ai_embeddings_per_post_enabled
|
return unless SiteSetting.ai_embeddings_per_post_enabled
|
||||||
|
|
||||||
# Now for posts
|
# Now for posts
|
||||||
table_name = vector_rep.post_table_name
|
table_name = DiscourseAi::Embeddings::Schema::POSTS_TABLE
|
||||||
|
posts_batch_size = 1000
|
||||||
|
|
||||||
posts =
|
posts =
|
||||||
Post
|
Post
|
||||||
.joins("LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id")
|
.joins("LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id")
|
||||||
.where(deleted_at: nil)
|
.where(deleted_at: nil)
|
||||||
.where(post_type: Post.types[:regular])
|
.where(post_type: Post.types[:regular])
|
||||||
.limit(limit - rebaked)
|
|
||||||
|
|
||||||
# First, we'll try to backfill embeddings for posts that have none
|
# First, we'll try to backfill embeddings for posts that have none
|
||||||
posts
|
posts
|
||||||
.where("#{table_name}.post_id IS NULL")
|
.where("#{table_name}.post_id IS NULL")
|
||||||
.find_in_batches do |batch|
|
.limit(limit - rebaked)
|
||||||
vector_rep.gen_bulk_reprensentations(batch)
|
.pluck(:id)
|
||||||
rebaked += batch.size
|
.each_slice(posts_batch_size) do |batch|
|
||||||
|
vector.gen_bulk_reprensentations(Post.where(id: batch))
|
||||||
|
rebaked += batch.length
|
||||||
end
|
end
|
||||||
|
|
||||||
return if rebaked >= limit
|
return if rebaked >= limit
|
||||||
@@ -87,48 +87,47 @@ module Jobs
|
|||||||
# embeddings, be it model or strategy version
|
# embeddings, be it model or strategy version
|
||||||
posts
|
posts
|
||||||
.where(<<~SQL)
|
.where(<<~SQL)
|
||||||
#{table_name}.model_version < #{vector_rep.version}
|
#{table_name}.model_version < #{vector_def.version}
|
||||||
OR
|
OR
|
||||||
#{table_name}.strategy_version < #{strategy.version}
|
#{table_name}.strategy_version < #{vector_def.strategy_version}
|
||||||
SQL
|
SQL
|
||||||
.find_in_batches do |batch|
|
.limit(limit - rebaked)
|
||||||
vector_rep.gen_bulk_reprensentations(batch)
|
.pluck(:id)
|
||||||
rebaked += batch.size
|
.each_slice(posts_batch_size) do |batch|
|
||||||
|
vector.gen_bulk_reprensentations(Post.where(id: batch))
|
||||||
|
rebaked += batch.length
|
||||||
end
|
end
|
||||||
|
|
||||||
return if rebaked >= limit
|
return if rebaked >= limit
|
||||||
|
|
||||||
# Finally, we'll try to backfill embeddings for posts that have outdated
|
# Finally, we'll try to backfill embeddings for posts that have outdated
|
||||||
# embeddings due to edits. Here we only do 10% of the limit
|
# embeddings due to edits. Here we only do 10% of the limit
|
||||||
posts_batch_size = 1000
|
posts
|
||||||
|
.where("#{table_name}.updated_at < ?", 7.days.ago)
|
||||||
outdated_post_ids =
|
.order("random()")
|
||||||
posts
|
.limit((limit - rebaked) / 10)
|
||||||
.where("#{table_name}.updated_at < ?", 7.days.ago)
|
.pluck(:id)
|
||||||
.order("random()")
|
.each_slice(posts_batch_size) do |batch|
|
||||||
.limit((limit - rebaked) / 10)
|
vector.gen_bulk_reprensentations(Post.where(id: batch))
|
||||||
.pluck(:id)
|
rebaked += batch.length
|
||||||
|
end
|
||||||
outdated_post_ids.each_slice(posts_batch_size) do |batch|
|
|
||||||
vector_rep.gen_bulk_reprensentations(Post.where(id: batch))
|
|
||||||
rebaked += batch.length
|
|
||||||
end
|
|
||||||
|
|
||||||
rebaked
|
rebaked
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def populate_topic_embeddings(vector_rep, topics, force: false)
|
def populate_topic_embeddings(vector, topics, force: false)
|
||||||
done = 0
|
done = 0
|
||||||
|
|
||||||
topics = topics.where("#{vector_rep.topic_table_name}.topic_id IS NULL") if !force
|
topics =
|
||||||
|
topics.where("#{DiscourseAi::Embeddings::Schema::TOPICS_TABLE}.topic_id IS NULL") if !force
|
||||||
|
|
||||||
ids = topics.pluck("topics.id")
|
ids = topics.pluck("topics.id")
|
||||||
batch_size = 1000
|
batch_size = 1000
|
||||||
|
|
||||||
ids.each_slice(batch_size) do |batch|
|
ids.each_slice(batch_size) do |batch|
|
||||||
vector_rep.gen_bulk_reprensentations(Topic.where(id: batch).order("topics.bumped_at DESC"))
|
vector.gen_bulk_reprensentations(Topic.where(id: batch).order("topics.bumped_at DESC"))
|
||||||
done += batch.length
|
done += batch.length
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class SentimentBackfill < ::Jobs::Scheduled
|
||||||
|
every 5.minutes
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(_args)
|
||||||
|
return if !SiteSetting.ai_sentiment_enabled
|
||||||
|
|
||||||
|
base_budget = SiteSetting.ai_sentiment_backfill_maximum_posts_per_hour
|
||||||
|
return if base_budget.zero?
|
||||||
|
# Split budget in 12 intervals, but make sure is at least one.
|
||||||
|
#
|
||||||
|
# This is not exact as we don't have a way of tracking how many
|
||||||
|
# posts we classified in the current hour, but it's a good enough approximation.
|
||||||
|
limit_per_job = [base_budget, 12].max / 12
|
||||||
|
|
||||||
|
classificator = DiscourseAi::Sentiment::PostClassification.new
|
||||||
|
return if !classificator.has_classifiers?
|
||||||
|
|
||||||
|
posts =
|
||||||
|
DiscourseAi::Sentiment::PostClassification.backfill_query(
|
||||||
|
max_age_days: SiteSetting.ai_sentiment_backfill_post_max_age_days,
|
||||||
|
).limit(limit_per_job)
|
||||||
|
|
||||||
|
classificator.bulk_classify!(posts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -12,22 +12,27 @@ module ::Jobs
|
|||||||
|
|
||||||
system_user = Discourse.system_user
|
system_user = Discourse.system_user
|
||||||
|
|
||||||
|
if SiteSetting.ai_summary_gists_enabled
|
||||||
|
gist_t = AiSummary.summary_types[:gist]
|
||||||
|
|
||||||
|
backfill_candidates(gist_t)
|
||||||
|
.limit(current_budget(gist_t))
|
||||||
|
.each do |topic|
|
||||||
|
DiscourseAi::Summarization.topic_gist(topic).force_summarize(system_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
complete_t = AiSummary.summary_types[:complete]
|
complete_t = AiSummary.summary_types[:complete]
|
||||||
backfill_candidates(complete_t)
|
backfill_candidates(complete_t)
|
||||||
.limit(current_budget(complete_t))
|
.limit(current_budget(complete_t))
|
||||||
.each do |topic|
|
.each do |topic|
|
||||||
DiscourseAi::Summarization.topic_summary(topic).force_summarize(system_user)
|
DiscourseAi::Summarization.topic_summary(topic).force_summarize(system_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless SiteSetting.ai_summary_gists_enabled
|
|
||||||
|
|
||||||
gist_t = AiSummary.summary_types[:gist]
|
|
||||||
backfill_candidates(gist_t)
|
|
||||||
.limit(current_budget(gist_t))
|
|
||||||
.each { |topic| DiscourseAi::Summarization.topic_gist(topic).force_summarize(system_user) }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def backfill_candidates(summary_type)
|
def backfill_candidates(summary_type)
|
||||||
|
max_age_days = SiteSetting.ai_summary_backfill_topic_max_age_days
|
||||||
|
|
||||||
Topic
|
Topic
|
||||||
.where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count)
|
.where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count)
|
||||||
.joins(<<~SQL)
|
.joins(<<~SQL)
|
||||||
@@ -36,9 +41,15 @@ module ::Jobs
|
|||||||
ais.target_type = 'Topic' AND
|
ais.target_type = 'Topic' AND
|
||||||
ais.summary_type = '#{summary_type}'
|
ais.summary_type = '#{summary_type}'
|
||||||
SQL
|
SQL
|
||||||
|
.where("topics.created_at > current_timestamp - INTERVAL '#{max_age_days.to_i} DAY'")
|
||||||
.where(
|
.where(
|
||||||
"ais.id IS NULL OR UPPER(ais.content_range) < topics.highest_post_number + 1",
|
<<~SQL, # (1..1) gets stored ad (1..2).
|
||||||
) # (1..1) gets stored ad (1..2).
|
ais.id IS NULL OR (
|
||||||
|
UPPER(ais.content_range) < topics.highest_post_number + 1
|
||||||
|
AND ais.created_at < (current_timestamp - INTERVAL '5 minutes')
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
)
|
||||||
.order("ais.created_at DESC NULLS FIRST, topics.last_posted_at DESC")
|
.order("ais.created_at DESC NULLS FIRST, topics.last_posted_at DESC")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class AiApiAuditLog < ActiveRecord::Base
|
|||||||
Ollama = 7
|
Ollama = 7
|
||||||
SambaNova = 8
|
SambaNova = 8
|
||||||
Mistral = 9
|
Mistral = 9
|
||||||
|
OpenRouter = 10
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_log_id
|
def next_log_id
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AiArtifact < ActiveRecord::Base
|
class AiArtifact < ActiveRecord::Base
|
||||||
|
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
validates :html, length: { maximum: 65_535 }
|
validates :html, length: { maximum: 65_535 }
|
||||||
validates :css, length: { maximum: 65_535 }
|
validates :css, length: { maximum: 65_535 }
|
||||||
validates :js, length: { maximum: 65_535 }
|
validates :js, length: { maximum: 65_535 }
|
||||||
|
|
||||||
def self.iframe_for(id)
|
def self.iframe_for(id, version = nil)
|
||||||
<<~HTML
|
<<~HTML
|
||||||
<div class='ai-artifact'>
|
<div class='ai-artifact'>
|
||||||
<iframe src='#{url(id)}' frameborder="0" height="100%" width="100%"></iframe>
|
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
|
||||||
<a href='#{url(id)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
|
<a href='#{url(id, version)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
|
||||||
</div>
|
</div>
|
||||||
HTML
|
HTML
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.url(id)
|
def self.url(id, version = nil)
|
||||||
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
|
url = Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
|
||||||
|
if version
|
||||||
|
"#{url}/#{version}"
|
||||||
|
else
|
||||||
|
url
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.share_publicly(id:, post:)
|
def self.share_publicly(id:, post:)
|
||||||
@@ -33,6 +39,37 @@ class AiArtifact < ActiveRecord::Base
|
|||||||
def url
|
def url
|
||||||
self.class.url(id)
|
self.class.url(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def apply_diff(html_diff: nil, css_diff: nil, js_diff: nil, change_description: nil)
|
||||||
|
differ = DiscourseAi::Utils::DiffUtils
|
||||||
|
|
||||||
|
html = html_diff ? differ.apply_hunk(self.html, html_diff) : self.html
|
||||||
|
css = css_diff ? differ.apply_hunk(self.css, css_diff) : self.css
|
||||||
|
js = js_diff ? differ.apply_hunk(self.js, js_diff) : self.js
|
||||||
|
|
||||||
|
create_new_version(html: html, css: css, js: js, change_description: change_description)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_new_version(html: nil, css: nil, js: nil, change_description: nil)
|
||||||
|
latest_version = versions.order(version_number: :desc).first
|
||||||
|
new_version_number = latest_version ? latest_version.version_number + 1 : 1
|
||||||
|
version = nil
|
||||||
|
|
||||||
|
transaction do
|
||||||
|
# Create the version record
|
||||||
|
version =
|
||||||
|
versions.create!(
|
||||||
|
version_number: new_version_number,
|
||||||
|
html: html || self.html,
|
||||||
|
css: css || self.css,
|
||||||
|
js: js || self.js,
|
||||||
|
change_description: change_description,
|
||||||
|
)
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
version
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
class AiArtifactVersion < ActiveRecord::Base
|
||||||
|
belongs_to :ai_artifact
|
||||||
|
validates :html, length: { maximum: 65_535 }
|
||||||
|
validates :css, length: { maximum: 65_535 }
|
||||||
|
validates :js, length: { maximum: 65_535 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: ai_artifact_versions
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# ai_artifact_id :bigint not null
|
||||||
|
# version_number :integer not null
|
||||||
|
# html :string(65535)
|
||||||
|
# css :string(65535)
|
||||||
|
# js :string(65535)
|
||||||
|
# metadata :jsonb
|
||||||
|
# change_description :string
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_ai_artifact_versions_on_ai_artifact_id_and_version_number (ai_artifact_id,version_number) UNIQUE
|
||||||
|
#
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user