Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
522c1aabb8 |
@@ -1,10 +1,3 @@
|
||||
< 3.5.0.beta7-dev: cd14b0c0bee0cf63c59b64b6f7213e31a37f11a7
|
||||
< 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
|
||||
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
|
||||
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
|
||||
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
|
||||
< 3.4.0.beta4-dev: a53719ab8eb071459f215227421b3ea4987e5f87
|
||||
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
|
||||
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
|
||||
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
|
||||
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
|
||||
|
||||
@@ -2,8 +2,3 @@ node_modules
|
||||
/gems
|
||||
/auto_generated
|
||||
.env
|
||||
evals/log
|
||||
evals/cases
|
||||
config/eval-llms.local.yml
|
||||
# this gets rid of search results from ag, ripgrep, etc
|
||||
public/ai-share/highlight.min.js
|
||||
|
||||
@@ -2,6 +2,3 @@ inherit_gem:
|
||||
rubocop-discourse: stree-compat.yml
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
|
||||
Style/GlobalVars:
|
||||
AllowedVariables: [$prometheus_client]
|
||||
|
||||
+45
-59
@@ -1,93 +1,79 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
activesupport (8.0.2)
|
||||
activesupport (7.1.3.4)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
mutex_m
|
||||
tzinfo (~> 2.0)
|
||||
ast (2.4.2)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
bigdecimal (3.1.8)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
drb (2.2.1)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.10.2)
|
||||
language_server-protocol (3.17.0.4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.6.6)
|
||||
minitest (5.25.5)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.1)
|
||||
json (2.7.2)
|
||||
language_server-protocol (3.17.0.3)
|
||||
minitest (5.24.1)
|
||||
mutex_m (0.2.0)
|
||||
parallel (1.25.1)
|
||||
parser (3.3.3.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
prettier_print (1.2.1)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
racc (1.8.0)
|
||||
rack (3.1.6)
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.10.0)
|
||||
rubocop (1.74.0)
|
||||
regexp_parser (2.9.2)
|
||||
rexml (3.3.9)
|
||||
rubocop (1.64.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.1)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.31.3)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-capybara (2.22.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-discourse (3.12.1)
|
||||
rubocop-capybara (2.21.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-discourse (3.8.1)
|
||||
activesupport (>= 6.1)
|
||||
lint_roller (>= 1.1.0)
|
||||
rubocop (>= 1.73.2)
|
||||
rubocop-capybara (>= 2.22.0)
|
||||
rubocop-factory_bot (>= 2.27.0)
|
||||
rubocop-rails (>= 2.30.3)
|
||||
rubocop (>= 1.59.0)
|
||||
rubocop-capybara (>= 2.0.0)
|
||||
rubocop-factory_bot (>= 2.0.0)
|
||||
rubocop-rails (>= 2.25.0)
|
||||
rubocop-rspec (>= 3.0.1)
|
||||
rubocop-rspec_rails (>= 2.31.0)
|
||||
rubocop-factory_bot (2.27.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rails (2.30.3)
|
||||
rubocop-rspec_rails (>= 2.30.0)
|
||||
rubocop-factory_bot (2.26.1)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rails (2.25.1)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rspec (3.5.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec_rails (2.31.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec (~> 3.5)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (3.0.2)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec_rails (2.30.0)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec (~> 3, >= 3.0.1)
|
||||
ruby-progressbar (1.13.0)
|
||||
securerandom (0.4.1)
|
||||
syntax_tree (6.2.0)
|
||||
prettier_print (>= 1.2.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
unicode-display_width (2.5.0)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@@ -97,4 +83,4 @@ DEPENDENCIES
|
||||
syntax_tree
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.6
|
||||
2.5.10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Civilized Discourse Construction Kit, Inc.
|
||||
Copyright (c) 2021 Civilized Discourse Construction Kit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -3,27 +3,3 @@
|
||||
**Plugin Summary**
|
||||
|
||||
For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco
|
||||
|
||||
### Evals
|
||||
|
||||
The directory `evals` contains AI evals for the Discourse AI plugin.
|
||||
You may create a local config by copying `config/eval-llms.yml` to `config/eval-llms.local.yml` and modifying the values.
|
||||
|
||||
To run them use:
|
||||
|
||||
cd evals
|
||||
./run --help
|
||||
|
||||
```
|
||||
Usage: evals/run [options]
|
||||
-e, --eval NAME Name of the evaluation to run
|
||||
--list-models List models
|
||||
-m, --model NAME Model to evaluate (will eval all models if not specified)
|
||||
-l, --list List evals
|
||||
```
|
||||
|
||||
To run evals you will need to configure API keys in your environment:
|
||||
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"tests": {
|
||||
"requiredPlugins": [
|
||||
"https://github.com/discourse/discourse-prometheus",
|
||||
"https://github.com/discourse/discourse-solved.git"
|
||||
]
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class AdminPluginsShowDiscourseAiEmbeddingsEdit extends DiscourseRoute {
|
||||
async model(params) {
|
||||
const allEmbeddings = this.modelFor(
|
||||
"adminPlugins.show.discourse-ai-embeddings"
|
||||
);
|
||||
const id = parseInt(params.id, 10);
|
||||
const record = allEmbeddings.findBy("id", id);
|
||||
record.provider_params = record.provider_params || {};
|
||||
return record;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
controller.set(
|
||||
"allEmbeddings",
|
||||
this.modelFor("adminPlugins.show.discourse-ai-embeddings")
|
||||
);
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class AdminPluginsShowDiscourseAiEmbeddingsNew extends DiscourseRoute {
|
||||
async model() {
|
||||
const record = this.store.createRecord("ai-embedding");
|
||||
record.provider_params = {};
|
||||
return record;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
controller.set(
|
||||
"allEmbeddings",
|
||||
this.modelFor("adminPlugins.show.discourse-ai-embeddings")
|
||||
);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class DiscourseAiAiEmbeddingsRoute extends DiscourseRoute {
|
||||
model() {
|
||||
return this.store.findAll("ai-embedding");
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
import SiteSetting from "admin/models/site-setting";
|
||||
|
||||
export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRoute {
|
||||
async model(params) {
|
||||
const allFeatures = this.modelFor(
|
||||
"adminPlugins.show.discourse-ai-features"
|
||||
);
|
||||
const id = parseInt(params.id, 10);
|
||||
const currentFeature = allFeatures.find((feature) => feature.id === id);
|
||||
|
||||
const { site_settings } = await ajax("/admin/config/site_settings.json", {
|
||||
data: {
|
||||
filter_area: `ai-features/${currentFeature.module_name}`,
|
||||
plugin: "discourse-ai",
|
||||
category: "discourse_ai",
|
||||
},
|
||||
});
|
||||
|
||||
currentFeature.feature_settings = site_settings.map((setting) =>
|
||||
SiteSetting.create(setting)
|
||||
);
|
||||
|
||||
return currentFeature;
|
||||
}
|
||||
|
||||
@action
|
||||
willTransition() {
|
||||
// site settings may amend if a feature is enabled or disabled, so refresh the model
|
||||
// even on back button
|
||||
this.router.refresh("adminPlugins.show.discourse-ai-features");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { service } from "@ember/service";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class AdminPluginsShowDiscourseAiFeatures extends DiscourseRoute {
|
||||
@service store;
|
||||
|
||||
async model() {
|
||||
return this.store.findAll("ai-feature");
|
||||
}
|
||||
}
|
||||
+1
-10
@@ -2,17 +2,8 @@ import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class AdminPluginsShowDiscourseAiLlmsEdit extends DiscourseRoute {
|
||||
async model(params) {
|
||||
const id = parseInt(params.id, 10);
|
||||
|
||||
if (id < 0) {
|
||||
// You shouldn't be able to access the edit page
|
||||
// if the model is seeded
|
||||
return this.router.transitionTo(
|
||||
"adminPlugins.show.discourse-ai-llms.index"
|
||||
);
|
||||
}
|
||||
|
||||
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
|
||||
const id = parseInt(params.id, 10);
|
||||
const record = allLlms.findBy("id", id);
|
||||
record.provider_params = record.provider_params || {};
|
||||
return record;
|
||||
|
||||
-2
@@ -14,7 +14,5 @@ export default class DiscourseAiToolsEditRoute extends DiscourseRoute {
|
||||
|
||||
controller.set("allTools", toolsModel);
|
||||
controller.set("presets", toolsModel.resultSetMeta.presets);
|
||||
controller.set("llms", toolsModel.resultSetMeta.llms);
|
||||
controller.set("settings", toolsModel.resultSetMeta.settings);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,10 +1,6 @@
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
|
||||
beforeModel(transition) {
|
||||
this.preset = transition.to.queryParams.presetId || "empty_tool";
|
||||
}
|
||||
|
||||
async model() {
|
||||
return this.store.createRecord("ai-tool");
|
||||
}
|
||||
@@ -12,10 +8,8 @@ export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
|
||||
setupController(controller) {
|
||||
super.setupController(...arguments);
|
||||
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
|
||||
|
||||
controller.set("allTools", toolsModel);
|
||||
controller.set("presets", toolsModel.resultSetMeta.presets);
|
||||
controller.set("llms", toolsModel.resultSetMeta.llms);
|
||||
controller.set("settings", toolsModel.resultSetMeta.settings);
|
||||
controller.set("selectedPreset", this.preset);
|
||||
}
|
||||
}
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
<AiEmbeddingsListEditor
|
||||
@embeddings={{this.allEmbeddings}}
|
||||
@currentEmbedding={{this.model}}
|
||||
/>
|
||||
-1
@@ -1 +0,0 @@
|
||||
<AiEmbeddingsListEditor @embeddings={{this.model}} />
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
<AiEmbeddingsListEditor
|
||||
@embeddings={{this.allEmbeddings}}
|
||||
@currentEmbedding={{this.model}}
|
||||
/>
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import RouteTemplate from "ember-route-template";
|
||||
import BackButton from "discourse/components/back-button";
|
||||
import SiteSettingComponent from "admin/components/site-setting";
|
||||
|
||||
export default RouteTemplate(
|
||||
<template>
|
||||
<BackButton
|
||||
@route="adminPlugins.show.discourse-ai-features"
|
||||
@label="discourse_ai.features.back"
|
||||
/>
|
||||
<section class="ai-feature-editor__header">
|
||||
<h2>{{@model.name}}</h2>
|
||||
<p>{{@model.description}}</p>
|
||||
</section>
|
||||
|
||||
<section class="ai-feature-editor">
|
||||
{{#each @model.feature_settings as |setting|}}
|
||||
<div>
|
||||
<SiteSettingComponent @setting={{setting}} />
|
||||
</div>
|
||||
{{/each}}
|
||||
</section>
|
||||
</template>
|
||||
);
|
||||
-1
@@ -1 +0,0 @@
|
||||
<AiFeatures @features={{this.model}} />
|
||||
-2
@@ -3,7 +3,5 @@
|
||||
@tools={{this.allTools}}
|
||||
@model={{this.model}}
|
||||
@presets={{this.presets}}
|
||||
@llms={{this.llms}}
|
||||
@settings={{this.settings}}
|
||||
/>
|
||||
</section>
|
||||
-3
@@ -3,8 +3,5 @@
|
||||
@tools={{this.allTools}}
|
||||
@model={{this.model}}
|
||||
@presets={{this.presets}}
|
||||
@llms={{this.llms}}
|
||||
@settings={{this.settings}}
|
||||
@selectedPreset={{this.selectedPreset}}
|
||||
/>
|
||||
</section>
|
||||
@@ -1,200 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiEmbeddingsController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def index
|
||||
embedding_defs = EmbeddingDefinition.all.order(:display_name)
|
||||
|
||||
render json: {
|
||||
ai_embeddings:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
embedding_defs,
|
||||
each_serializer: AiEmbeddingDefinitionSerializer,
|
||||
root: false,
|
||||
).as_json,
|
||||
meta: {
|
||||
provider_params: EmbeddingDefinition.provider_params,
|
||||
providers: EmbeddingDefinition.provider_names,
|
||||
distance_functions: EmbeddingDefinition.distance_functions,
|
||||
tokenizers:
|
||||
EmbeddingDefinition.tokenizer_names.map { |tn|
|
||||
{ id: tn, name: tn.split("::").last }
|
||||
},
|
||||
presets: EmbeddingDefinition.presets,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def edit
|
||||
embedding_def = EmbeddingDefinition.find(params[:id])
|
||||
render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
|
||||
end
|
||||
|
||||
def create
|
||||
embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
|
||||
|
||||
if embedding_def.save
|
||||
log_ai_embedding_creation(embedding_def)
|
||||
render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created
|
||||
else
|
||||
render_json_error embedding_def
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
embedding_def = EmbeddingDefinition.find(params[:id])
|
||||
|
||||
if embedding_def.seeded?
|
||||
return(
|
||||
render_json_error(I18n.t("discourse_ai.embeddings.cannot_edit_builtin"), status: 403)
|
||||
)
|
||||
end
|
||||
|
||||
initial_attributes = embedding_def.attributes.dup
|
||||
|
||||
if embedding_def.update(ai_embeddings_params.except(:dimensions))
|
||||
log_ai_embedding_update(embedding_def, initial_attributes)
|
||||
render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
|
||||
else
|
||||
render_json_error embedding_def
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
embedding_def = EmbeddingDefinition.find(params[:id])
|
||||
|
||||
if embedding_def.seeded?
|
||||
return(
|
||||
render_json_error(I18n.t("discourse_ai.embeddings.cannot_edit_builtin"), status: 403)
|
||||
)
|
||||
end
|
||||
|
||||
if embedding_def.id == SiteSetting.ai_embeddings_selected_model.to_i
|
||||
return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409)
|
||||
end
|
||||
|
||||
embedding_details = {
|
||||
embedding_id: embedding_def.id,
|
||||
display_name: embedding_def.display_name,
|
||||
provider: embedding_def.provider,
|
||||
dimensions: embedding_def.dimensions,
|
||||
subject: embedding_def.display_name,
|
||||
}
|
||||
|
||||
if embedding_def.destroy
|
||||
log_ai_embedding_deletion(embedding_details)
|
||||
head :no_content
|
||||
else
|
||||
render_json_error embedding_def
|
||||
end
|
||||
end
|
||||
|
||||
def test
|
||||
RateLimiter.new(
|
||||
current_user,
|
||||
"ai_embeddings_test_#{current_user.id}",
|
||||
3,
|
||||
1.minute,
|
||||
).performed!
|
||||
|
||||
embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
|
||||
DiscourseAi::Embeddings::Vector.new(embedding_def).vector_from("this is a test")
|
||||
|
||||
render json: { success: true }
|
||||
rescue Net::HTTPBadResponse => e
|
||||
render json: { success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ai_embeddings_params
|
||||
permitted =
|
||||
params.require(:ai_embedding).permit(
|
||||
:display_name,
|
||||
:dimensions,
|
||||
:max_sequence_length,
|
||||
:pg_function,
|
||||
:provider,
|
||||
:url,
|
||||
:api_key,
|
||||
:tokenizer_class,
|
||||
:embed_prompt,
|
||||
:search_prompt,
|
||||
:matryoshka_dimensions,
|
||||
)
|
||||
|
||||
extra_field_names = EmbeddingDefinition.provider_params.dig(permitted[:provider]&.to_sym)
|
||||
if extra_field_names.present?
|
||||
received_prov_params =
|
||||
params.dig(:ai_embedding, :provider_params)&.slice(*extra_field_names.keys)
|
||||
|
||||
if received_prov_params.present?
|
||||
permitted[:provider_params] = received_prov_params.permit!
|
||||
end
|
||||
end
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
def ai_embeddings_logger_fields
|
||||
{
|
||||
display_name: {
|
||||
},
|
||||
provider: {
|
||||
},
|
||||
dimensions: {
|
||||
},
|
||||
url: {
|
||||
},
|
||||
tokenizer_class: {
|
||||
},
|
||||
max_sequence_length: {
|
||||
},
|
||||
embed_prompt: {
|
||||
type: :large_text,
|
||||
},
|
||||
search_prompt: {
|
||||
type: :large_text,
|
||||
},
|
||||
matryoshka_dimensions: {
|
||||
},
|
||||
api_key: {
|
||||
type: :sensitive,
|
||||
},
|
||||
# JSON fields should be tracked as simple changes
|
||||
json_fields: [:provider_params],
|
||||
}
|
||||
end
|
||||
|
||||
def log_ai_embedding_creation(embedding_def)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
|
||||
logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details)
|
||||
end
|
||||
|
||||
def log_ai_embedding_update(embedding_def, initial_attributes)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
|
||||
|
||||
logger.log_update(
|
||||
"embedding",
|
||||
embedding_def,
|
||||
initial_attributes,
|
||||
ai_embeddings_logger_fields,
|
||||
entity_details,
|
||||
)
|
||||
end
|
||||
|
||||
def log_ai_embedding_deletion(embedding_details)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
logger.log_deletion("embedding", embedding_details)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,66 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiFeaturesController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def index
|
||||
render json: serialize_modules(DiscourseAi::Configuration::Module.all)
|
||||
end
|
||||
|
||||
def edit
|
||||
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
|
||||
|
||||
a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i)
|
||||
|
||||
render json: serialize_module(a_module)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_modules(modules)
|
||||
modules.map { |a_module| serialize_module(a_module) }
|
||||
end
|
||||
|
||||
def serialize_module(a_module)
|
||||
return nil if a_module.blank?
|
||||
|
||||
{
|
||||
id: a_module.id,
|
||||
module_name: a_module.name,
|
||||
module_enabled: a_module.enabled?,
|
||||
features: a_module.features.map { |f| serialize_feature(f) },
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_feature(feature)
|
||||
{
|
||||
name: feature.name,
|
||||
personas: feature.persona_ids.map { |id| serialize_persona(persona_id_obj_hash[id]) },
|
||||
llm_models:
|
||||
feature.llm_models.map do |llm_model|
|
||||
{ id: llm_model.id, name: llm_model.display_name }
|
||||
end,
|
||||
enabled: feature.enabled?,
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_persona(persona)
|
||||
return nil if persona.blank?
|
||||
|
||||
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def persona_id_obj_hash
|
||||
@persona_id_obj_hash ||=
|
||||
begin
|
||||
ids = DiscourseAi::Configuration::Feature.all.map(&:persona_ids).flatten.uniq
|
||||
AiPersona.where(id: ids).index_by(&:id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
|
||||
def index
|
||||
llms = LlmModel.all.includes(:llm_quotas).order(:display_name)
|
||||
llms = LlmModel.all.order(:display_name)
|
||||
|
||||
render json: {
|
||||
ai_llms:
|
||||
@@ -40,14 +40,8 @@ module DiscourseAi
|
||||
|
||||
def create
|
||||
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
|
||||
llm_model.toggle_companion_user
|
||||
log_llm_model_creation(llm_model)
|
||||
render json: LlmModelSerializer.new(llm_model), status: :created
|
||||
else
|
||||
render_json_error llm_model
|
||||
@@ -57,36 +51,12 @@ module DiscourseAi
|
||||
def update
|
||||
llm_model = LlmModel.find(params[:id])
|
||||
|
||||
# Capture initial state for logging
|
||||
initial_attributes = llm_model.attributes.dup
|
||||
initial_quotas = llm_model.llm_quotas.map(&:attributes)
|
||||
|
||||
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?
|
||||
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
|
||||
end
|
||||
|
||||
if llm_model.update(ai_llm_params(updating: llm_model))
|
||||
llm_model.toggle_companion_user
|
||||
log_llm_model_update(llm_model, initial_attributes, initial_quotas)
|
||||
render json: LlmModelSerializer.new(llm_model)
|
||||
else
|
||||
render_json_error llm_model
|
||||
@@ -115,20 +85,11 @@ module DiscourseAi
|
||||
)
|
||||
end
|
||||
|
||||
# Capture model details for logging before destruction
|
||||
model_details = {
|
||||
model_id: llm_model.id,
|
||||
display_name: llm_model.display_name,
|
||||
name: llm_model.name,
|
||||
provider: llm_model.provider,
|
||||
}
|
||||
|
||||
# Clean up companion users
|
||||
llm_model.enabled_chat_bot = false
|
||||
llm_model.toggle_companion_user
|
||||
|
||||
if llm_model.destroy
|
||||
log_llm_model_deletion(model_details)
|
||||
head :no_content
|
||||
else
|
||||
render_json_error llm_model
|
||||
@@ -149,19 +110,6 @@ module DiscourseAi
|
||||
|
||||
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)
|
||||
return {} if params[:ai_llm].blank?
|
||||
|
||||
@@ -172,13 +120,9 @@ module DiscourseAi
|
||||
:provider,
|
||||
:tokenizer,
|
||||
:max_prompt_tokens,
|
||||
:max_output_tokens,
|
||||
:api_key,
|
||||
:enabled_chat_bot,
|
||||
:vision_enabled,
|
||||
:input_cost,
|
||||
:cached_input_cost,
|
||||
:output_cost,
|
||||
)
|
||||
|
||||
provider = updating ? updating.provider : permitted[:provider]
|
||||
@@ -205,89 +149,6 @@ module DiscourseAi
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
def ai_llm_logger_fields
|
||||
{
|
||||
display_name: {
|
||||
},
|
||||
name: {
|
||||
},
|
||||
provider: {
|
||||
},
|
||||
tokenizer: {
|
||||
},
|
||||
url: {
|
||||
},
|
||||
max_prompt_tokens: {
|
||||
},
|
||||
max_output_tokens: {
|
||||
},
|
||||
enabled_chat_bot: {
|
||||
},
|
||||
vision_enabled: {
|
||||
},
|
||||
api_key: {
|
||||
type: :sensitive,
|
||||
},
|
||||
input_cost: {
|
||||
},
|
||||
output_cost: {
|
||||
},
|
||||
# JSON fields should be tracked as simple changes
|
||||
json_fields: [:provider_params],
|
||||
}
|
||||
end
|
||||
|
||||
def log_llm_model_creation(llm_model)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
|
||||
|
||||
# Add quota information as a special case
|
||||
if llm_model.llm_quotas.any?
|
||||
entity_details[:quotas] = llm_model
|
||||
.llm_quotas
|
||||
.map do |quota|
|
||||
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
|
||||
end
|
||||
.join("; ")
|
||||
end
|
||||
|
||||
logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details)
|
||||
end
|
||||
|
||||
def log_llm_model_update(llm_model, initial_attributes, initial_quotas)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
|
||||
|
||||
# Track quota changes separately as they're a special case
|
||||
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
|
||||
if initial_quotas != current_quotas
|
||||
initial_quota_summary =
|
||||
initial_quotas
|
||||
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
|
||||
.join("; ")
|
||||
current_quota_summary =
|
||||
current_quotas
|
||||
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
|
||||
.join("; ")
|
||||
entity_details[:quotas_changed] = true
|
||||
entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}"
|
||||
end
|
||||
|
||||
logger.log_update(
|
||||
"llm_model",
|
||||
llm_model,
|
||||
initial_attributes,
|
||||
ai_llm_logger_fields,
|
||||
entity_details,
|
||||
)
|
||||
end
|
||||
|
||||
def log_llm_model_deletion(model_details)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
model_details[:subject] = model_details[:display_name]
|
||||
logger.log_deletion("llm_model", model_details)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,49 +5,32 @@ module DiscourseAi
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
before_action :find_ai_persona, only: %i[edit update destroy create_user export]
|
||||
before_action :find_ai_persona, only: %i[edit update destroy create_user]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
AiPersona
|
||||
.ordered
|
||||
.includes(:user, :uploads)
|
||||
.map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) }
|
||||
|
||||
AiPersona.ordered.map do |persona|
|
||||
# we use a special serializer here cause names and descriptions are
|
||||
# localized for system personas
|
||||
LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
end
|
||||
tools =
|
||||
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
|
||||
AiToolSerializer.new(tool, root: false)
|
||||
end
|
||||
|
||||
AiTool
|
||||
.where(enabled: true)
|
||||
.each do |tool|
|
||||
tools << {
|
||||
id: "custom-#{tool.id}",
|
||||
name:
|
||||
I18n.t(
|
||||
"discourse_ai.tools.custom_name",
|
||||
name: tool.name.capitalize,
|
||||
tool_name: tool.tool_name,
|
||||
),
|
||||
name: I18n.t("discourse_ai.tools.custom_name", name: tool.name.capitalize),
|
||||
}
|
||||
end
|
||||
|
||||
llms =
|
||||
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
|
||||
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
|
||||
)
|
||||
|
||||
render json: {
|
||||
ai_personas: ai_personas,
|
||||
meta: {
|
||||
tools: tools,
|
||||
llms: llms,
|
||||
settings: {
|
||||
rag_images_enabled: SiteSetting.ai_rag_images_enabled,
|
||||
},
|
||||
},
|
||||
}
|
||||
DiscourseAi::Configuration::LlmEnumerator.values.map do |hash|
|
||||
{ id: hash[:value], name: hash[:name] }
|
||||
end
|
||||
render json: { ai_personas: ai_personas, meta: { tools: tools, llms: llms } }
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -61,7 +44,6 @@ module DiscourseAi
|
||||
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
|
||||
if ai_persona.save
|
||||
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
|
||||
log_ai_persona_creation(ai_persona)
|
||||
|
||||
render json: {
|
||||
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
|
||||
@@ -78,11 +60,8 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def update
|
||||
initial_attributes = @ai_persona.attributes.dup
|
||||
|
||||
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
|
||||
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
|
||||
log_ai_persona_update(@ai_persona, initial_attributes)
|
||||
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
||||
else
|
||||
@@ -91,57 +70,13 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def destroy
|
||||
persona_details = {
|
||||
persona_id: @ai_persona.id,
|
||||
name: @ai_persona.name,
|
||||
description: @ai_persona.description,
|
||||
}
|
||||
|
||||
if @ai_persona.destroy
|
||||
log_ai_persona_deletion(persona_details)
|
||||
head :no_content
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def export
|
||||
persona = AiPersona.find(params[:id])
|
||||
exporter = DiscourseAi::PersonaExporter.new(persona: persona)
|
||||
|
||||
response.headers[
|
||||
"Content-Disposition"
|
||||
] = "attachment; filename=\"#{persona.name.parameterize}.json\""
|
||||
|
||||
render json: exporter.export
|
||||
end
|
||||
|
||||
def import
|
||||
name = params.dig(:persona, :name)
|
||||
existing_persona = AiPersona.find_by(name: name)
|
||||
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
|
||||
|
||||
begin
|
||||
importer = DiscourseAi::PersonaImporter.new(json: params.to_unsafe_h)
|
||||
|
||||
if existing_persona && force_update
|
||||
initial_attributes = existing_persona.attributes.dup
|
||||
persona = importer.import!(overwrite: true)
|
||||
log_ai_persona_update(persona, initial_attributes)
|
||||
render json: LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
else
|
||||
persona = importer.import!
|
||||
log_ai_persona_creation(persona)
|
||||
render json: LocalizedAiPersonaSerializer.new(persona, root: false), status: :created
|
||||
end
|
||||
rescue DiscourseAi::PersonaImporter::ImportError => e
|
||||
render_json_error e.message, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("AI Persona import failed: #{e.message}")
|
||||
render_json_error "Import failed: #{e.message}", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def stream_reply
|
||||
persona =
|
||||
AiPersona.find_by(name: params[:persona_name]) ||
|
||||
@@ -247,7 +182,7 @@ module DiscourseAi
|
||||
:priority,
|
||||
:top_p,
|
||||
:temperature,
|
||||
:default_llm_id,
|
||||
:default_llm,
|
||||
:user_id,
|
||||
:max_context_posts,
|
||||
:vision_enabled,
|
||||
@@ -255,8 +190,7 @@ module DiscourseAi
|
||||
:rag_chunk_tokens,
|
||||
:rag_chunk_overlap_tokens,
|
||||
:rag_conversation_chunks,
|
||||
:rag_llm_model_id,
|
||||
:question_consolidator_llm_id,
|
||||
:question_consolidator_llm,
|
||||
:allow_chat_channel_mentions,
|
||||
:allow_chat_direct_messages,
|
||||
:allow_topic_mentions,
|
||||
@@ -272,14 +206,6 @@ module DiscourseAi
|
||||
permitted[:tools] = permit_tools(tools)
|
||||
end
|
||||
|
||||
if response_format = params.dig(:ai_persona, :response_format)
|
||||
permitted[:response_format] = permit_response_format(response_format)
|
||||
end
|
||||
|
||||
if examples = params.dig(:ai_persona, :examples)
|
||||
permitted[:examples] = permit_examples(examples)
|
||||
end
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
@@ -294,110 +220,6 @@ module DiscourseAi
|
||||
[tool, options, !!force_tool]
|
||||
end
|
||||
end
|
||||
|
||||
def permit_response_format(response_format)
|
||||
return [] if !response_format.is_a?(Array)
|
||||
|
||||
response_format.map do |element|
|
||||
if element && element.is_a?(ActionController::Parameters)
|
||||
element.permit!
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def permit_examples(examples)
|
||||
return [] if !examples.is_a?(Array)
|
||||
|
||||
examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
|
||||
end
|
||||
|
||||
def ai_persona_logger_fields
|
||||
{
|
||||
name: {
|
||||
},
|
||||
description: {
|
||||
},
|
||||
enabled: {
|
||||
},
|
||||
priority: {
|
||||
},
|
||||
system_prompt: {
|
||||
type: :large_text,
|
||||
},
|
||||
default_llm_id: {
|
||||
},
|
||||
temperature: {
|
||||
},
|
||||
top_p: {
|
||||
},
|
||||
user_id: {
|
||||
},
|
||||
max_context_posts: {
|
||||
},
|
||||
vision_enabled: {
|
||||
},
|
||||
vision_max_pixels: {
|
||||
},
|
||||
rag_chunk_tokens: {
|
||||
},
|
||||
rag_chunk_overlap_tokens: {
|
||||
},
|
||||
rag_conversation_chunks: {
|
||||
},
|
||||
rag_llm_model_id: {
|
||||
},
|
||||
question_consolidator_llm_id: {
|
||||
},
|
||||
allow_chat_channel_mentions: {
|
||||
},
|
||||
allow_chat_direct_messages: {
|
||||
},
|
||||
allow_topic_mentions: {
|
||||
},
|
||||
allow_personal_messages: {
|
||||
},
|
||||
tool_details: {
|
||||
type: :large_text,
|
||||
},
|
||||
forced_tool_count: {
|
||||
},
|
||||
force_default_llm: {
|
||||
},
|
||||
# JSON fields
|
||||
json_fields: %i[tools response_format examples allowed_group_ids],
|
||||
}
|
||||
end
|
||||
|
||||
def log_ai_persona_creation(ai_persona)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
|
||||
entity_details[:tools_count] = (ai_persona.tools || []).size
|
||||
|
||||
logger.log_creation("persona", ai_persona, ai_persona_logger_fields, entity_details)
|
||||
end
|
||||
|
||||
def log_ai_persona_update(ai_persona, initial_attributes)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
|
||||
entity_details[:tools_count] = ai_persona.tools.size if ai_persona.tools.present?
|
||||
|
||||
logger.log_update(
|
||||
"persona",
|
||||
ai_persona,
|
||||
initial_attributes,
|
||||
ai_persona_logger_fields,
|
||||
entity_details,
|
||||
)
|
||||
end
|
||||
|
||||
def log_ai_persona_deletion(persona_details)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
persona_details[:subject] = persona_details[:name]
|
||||
|
||||
logger.log_deletion("persona", persona_details)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,23 +10,12 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def update
|
||||
initial_settings = AiModerationSetting.spam
|
||||
|
||||
initial_data = {
|
||||
custom_instructions: initial_settings&.data&.dig("custom_instructions"),
|
||||
llm_model_id: initial_settings&.llm_model_id,
|
||||
ai_persona_id: initial_settings&.ai_persona_id,
|
||||
}
|
||||
|
||||
initial_custom_instructions = initial_settings&.data&.dig("custom_instructions")
|
||||
initial_llm_model_id = initial_settings&.llm_model_id
|
||||
|
||||
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,
|
||||
"custom:#{llm_model_id}",
|
||||
)
|
||||
return(
|
||||
render_json_error(
|
||||
@@ -36,22 +25,6 @@ module DiscourseAi
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if allowed_params.key?(:ai_persona_id)
|
||||
updated_params[:ai_persona_id] = allowed_params[:ai_persona_id]
|
||||
persona = AiPersona.find_by(id: allowed_params[:ai_persona_id])
|
||||
if persona.nil? ||
|
||||
persona.response_format.to_a.none? { |rf|
|
||||
rf["key"] == "spam" && rf["type"] == "boolean"
|
||||
}
|
||||
return(
|
||||
render_json_error(
|
||||
I18n.t("discourse_ai.llm.configuration.invalid_persona_response_format"),
|
||||
status: 422,
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
updated_params[:data] = {
|
||||
custom_instructions: allowed_params[:custom_instructions],
|
||||
} if allowed_params.key?(:custom_instructions)
|
||||
@@ -63,8 +36,6 @@ module DiscourseAi
|
||||
else
|
||||
AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
|
||||
end
|
||||
|
||||
log_ai_spam_update(initial_data, allowed_params)
|
||||
end
|
||||
|
||||
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
|
||||
@@ -79,7 +50,7 @@ module DiscourseAi
|
||||
)
|
||||
end
|
||||
|
||||
SiteSetting.set_and_log("ai_spam_detection_enabled", is_enabled, current_user)
|
||||
SiteSetting.ai_spam_detection_enabled = is_enabled
|
||||
end
|
||||
|
||||
render json: AiSpamSerializer.new(spam_config, root: false)
|
||||
@@ -115,71 +86,10 @@ module DiscourseAi
|
||||
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 log_ai_spam_update(initial_data, params)
|
||||
changes_to_log = {}
|
||||
|
||||
initial_llm_model_id = initial_data[:llm_model_id]
|
||||
if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s
|
||||
old_model_name =
|
||||
LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id
|
||||
new_model_name =
|
||||
LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id]
|
||||
|
||||
changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}"
|
||||
end
|
||||
|
||||
initial_custom_instructions = initial_data[:custom_instructions]
|
||||
if params.key?(:custom_instructions) &&
|
||||
initial_custom_instructions != params[:custom_instructions]
|
||||
changes_to_log[:custom_instructions] = params[:custom_instructions]
|
||||
end
|
||||
|
||||
initial_ai_persona_id = initial_data[:ai_persona_id]
|
||||
if params.key?(:ai_persona_id) && initial_ai_persona_id.to_s != params[:ai_persona_id].to_s
|
||||
old_persona_name =
|
||||
AiPersona.find_by(id: initial_ai_persona_id)&.name || initial_ai_persona_id
|
||||
new_persona_name =
|
||||
AiPersona.find_by(id: params[:ai_persona_id])&.name || params[:ai_persona_id]
|
||||
|
||||
changes_to_log[:ai_persona_id] = "#{old_persona_name} → #{new_persona_name}"
|
||||
end
|
||||
|
||||
if changes_to_log.present?
|
||||
changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject")
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
logger.log_custom("update_ai_spam_settings", changes_to_log)
|
||||
end
|
||||
end
|
||||
|
||||
def allowed_params
|
||||
params.permit(:is_enabled, :llm_model_id, :custom_instructions, :ai_persona_id)
|
||||
params.permit(:is_enabled, :llm_model_id, :custom_instructions)
|
||||
end
|
||||
|
||||
def spam_config
|
||||
|
||||
@@ -5,7 +5,7 @@ module DiscourseAi
|
||||
class AiToolsController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
before_action :find_ai_tool, only: %i[test edit update destroy export]
|
||||
before_action :find_ai_tool, only: %i[test edit update destroy]
|
||||
|
||||
def index
|
||||
ai_tools = AiTool.all
|
||||
@@ -25,58 +25,15 @@ module DiscourseAi
|
||||
|
||||
if ai_tool.save
|
||||
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
|
||||
log_ai_tool_creation(ai_tool)
|
||||
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
|
||||
else
|
||||
render_json_error ai_tool
|
||||
end
|
||||
end
|
||||
|
||||
def export
|
||||
response.headers[
|
||||
"Content-Disposition"
|
||||
] = "attachment; filename=\"#{@ai_tool.tool_name}.json\""
|
||||
render_serialized(@ai_tool, AiCustomToolSerializer)
|
||||
end
|
||||
|
||||
def import
|
||||
existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name])
|
||||
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
|
||||
|
||||
if existing_tool && !force_update
|
||||
return(
|
||||
render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.",
|
||||
status: :conflict
|
||||
)
|
||||
end
|
||||
|
||||
if existing_tool && force_update
|
||||
initial_attributes = existing_tool.attributes.dup
|
||||
if existing_tool.update(ai_tool_params)
|
||||
log_ai_tool_update(existing_tool, initial_attributes)
|
||||
render_serialized(existing_tool, AiCustomToolSerializer)
|
||||
else
|
||||
render_json_error existing_tool
|
||||
end
|
||||
else
|
||||
ai_tool = AiTool.new(ai_tool_params)
|
||||
ai_tool.created_by_id = current_user.id
|
||||
|
||||
if ai_tool.save
|
||||
log_ai_tool_creation(ai_tool)
|
||||
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
|
||||
else
|
||||
render_json_error ai_tool
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
initial_attributes = @ai_tool.attributes.dup
|
||||
|
||||
if @ai_tool.update(ai_tool_params)
|
||||
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
|
||||
log_ai_tool_update(@ai_tool, initial_attributes)
|
||||
render_serialized(@ai_tool, AiCustomToolSerializer)
|
||||
else
|
||||
render_json_error @ai_tool
|
||||
@@ -84,15 +41,7 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def destroy
|
||||
tool_logger_details = {
|
||||
tool_id: @ai_tool.id,
|
||||
name: @ai_tool.name,
|
||||
tool_name: @ai_tool.tool_name,
|
||||
subject: @ai_tool.name,
|
||||
}
|
||||
|
||||
if @ai_tool.destroy
|
||||
log_ai_tool_deletion(tool_logger_details)
|
||||
head :no_content
|
||||
else
|
||||
render_json_error @ai_tool
|
||||
@@ -106,7 +55,7 @@ module DiscourseAi
|
||||
# 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)
|
||||
runner = @ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {})
|
||||
result = runner.invoke
|
||||
|
||||
if result.is_a?(Hash) && result[:error]
|
||||
@@ -135,72 +84,16 @@ module DiscourseAi
|
||||
.require(:ai_tool)
|
||||
.permit(
|
||||
:name,
|
||||
:tool_name,
|
||||
:description,
|
||||
:script,
|
||||
:summary,
|
||||
:rag_chunk_tokens,
|
||||
:rag_chunk_overlap_tokens,
|
||||
:rag_llm_model_id,
|
||||
rag_uploads: [:id],
|
||||
parameters: [:name, :type, :description, :required, enum: []],
|
||||
)
|
||||
.except(:rag_uploads)
|
||||
end
|
||||
|
||||
def ai_tool_logger_fields
|
||||
{
|
||||
name: {
|
||||
},
|
||||
tool_name: {
|
||||
},
|
||||
description: {
|
||||
},
|
||||
summary: {
|
||||
},
|
||||
enabled: {
|
||||
},
|
||||
rag_chunk_tokens: {
|
||||
},
|
||||
rag_chunk_overlap_tokens: {
|
||||
},
|
||||
rag_llm_model_id: {
|
||||
},
|
||||
script: {
|
||||
type: :large_text,
|
||||
},
|
||||
parameters: {
|
||||
type: :large_text,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def log_ai_tool_creation(ai_tool)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
|
||||
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
|
||||
entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present?
|
||||
|
||||
logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details)
|
||||
end
|
||||
|
||||
def log_ai_tool_update(ai_tool, initial_attributes)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
|
||||
|
||||
logger.log_update(
|
||||
"tool",
|
||||
ai_tool,
|
||||
initial_attributes,
|
||||
ai_tool_logger_fields,
|
||||
entity_details,
|
||||
)
|
||||
end
|
||||
|
||||
def log_ai_tool_deletion(tool_details)
|
||||
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||
logger.log_deletion("tool", tool_details)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ module DiscourseAi
|
||||
def upload_file
|
||||
file = params[:file] || params[:files].first
|
||||
|
||||
if !DiscourseAi::Embeddings.enabled?
|
||||
if !SiteSetting.ai_embeddings_enabled?
|
||||
raise Discourse::InvalidAccess.new("Embeddings not enabled")
|
||||
end
|
||||
|
||||
@@ -48,8 +48,7 @@ module DiscourseAi
|
||||
|
||||
def validate_extension!(filename)
|
||||
extension = File.extname(filename)[1..-1] || ""
|
||||
authorized_extensions = %w[txt md pdf]
|
||||
authorized_extensions.concat(%w[png jpg jpeg]) if SiteSetting.ai_rag_images_enabled
|
||||
authorized_extensions = %w[txt md]
|
||||
if !authorized_extensions.include?(extension)
|
||||
raise Discourse::InvalidParameters.new(
|
||||
I18n.t(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user