diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js
index ef2245f11..82e90e74e 100644
--- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js
+++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js
@@ -1,4 +1,6 @@
+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) {
@@ -6,7 +8,20 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
"adminPlugins.show.discourse-ai-features"
);
const id = parseInt(params.id, 10);
+ const currentFeature = allFeatures.find((feature) => feature.id === id);
- return allFeatures.find((feature) => feature.id === id);
+ const { site_settings } = await ajax("/admin/config/site_settings.json", {
+ data: {
+ filter_area: `ai-features/${currentFeature.ref}`,
+ plugin: "discourse-ai",
+ category: "discourse_ai",
+ },
+ });
+
+ currentFeature.feature_settings = site_settings.map((setting) =>
+ SiteSetting.create(setting)
+ );
+
+ return currentFeature;
}
}
diff --git a/app/controllers/discourse_ai/admin/ai_features_controller.rb b/app/controllers/discourse_ai/admin/ai_features_controller.rb
index 154db086a..ad258c6c5 100644
--- a/app/controllers/discourse_ai/admin/ai_features_controller.rb
+++ b/app/controllers/discourse_ai/admin/ai_features_controller.rb
@@ -6,99 +6,30 @@ class AiFeaturesController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
- render json: persona_backed_features
+ render json: serialize_features(DiscourseAi::Features.features)
end
def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
- render json: find_feature_by_id(params[:id].to_i)
+ render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
end
- def update
- raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
- raise Discourse::InvalidParameters.new(:ai_feature) if params[:ai_feature].blank?
- if params[:ai_feature][:persona_id].blank?
- raise Discourse::InvalidParameters.new(:persona_id)
- end
- raise Discourse::InvalidParameters.new(:enabled) if params[:ai_feature][:enabled].nil?
-
- feature = find_feature_by_id(params[:id].to_i)
- enable_value = params[:ai_feature][:enabled]
- persona_id = params[:ai_feature][:persona_id]
-
- SiteSetting.set_and_log(feature[:enable_setting][:name], enable_value, guardian.user)
- SiteSetting.set_and_log(feature[:persona_setting][:name], persona_id, guardian.user)
+ private
- render json: find_feature_by_id(params[:id].to_i)
+ def serialize_features(features)
+ features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) }
end
- private
+ def serialize_feature(feature)
+ return nil if feature.blank?
- # Eventually we may move this all to an active record model
- # but for now we are just using a hash
- # to store the features and their corresponding settings
- def feature_config
- [
- {
- id: 1,
- name_key: "discourse_ai.features.summarization.name",
- description_key: "discourse_ai.features.summarization.description",
- persona_setting_name: "ai_summarization_persona",
- enable_setting_name: "ai_summarization_enabled",
- },
- {
- id: 2,
- name_key: "discourse_ai.features.gists.name",
- description_key: "discourse_ai.features.gists.description",
- persona_setting_name: "ai_summary_gists_persona",
- enable_setting_name: "ai_summary_gists_enabled",
- },
- {
- id: 3,
- name_key: "discourse_ai.features.discoveries.name",
- description_key: "discourse_ai.features.discoveries.description",
- persona_setting_name: "ai_bot_discover_persona",
- enable_setting_name: "ai_bot_enabled",
- },
- {
- id: 4,
- name_key: "discourse_ai.features.discord_search.name",
- description_key: "discourse_ai.features.discord_search.description",
- persona_setting_name: "ai_discord_search_persona",
- enable_setting_name: "ai_discord_search_enabled",
- },
- ]
+ feature.merge(persona: serialize_persona(feature[:persona]))
end
- def persona_backed_features
- feature_config.map do |feature|
- {
- id: feature[:id],
- name: I18n.t(feature[:name_key]),
- description: I18n.t(feature[:description_key]),
- persona:
- serialize_data(
- AiPersona.find_by(id: SiteSetting.get(feature[:persona_setting_name])),
- AiFeaturesPersonaSerializer,
- root: false,
- ),
- persona_setting: {
- name: feature[:persona_setting_name],
- value: SiteSetting.get(feature[:persona_setting_name]),
- type: SiteSetting.type_supervisor.get_type(feature[:persona_setting_name]),
- },
- enable_setting: {
- name: feature[:enable_setting_name],
- value: SiteSetting.get(feature[:enable_setting_name]),
- type: SiteSetting.type_supervisor.get_type(feature[:enable_setting_name]),
- },
- }
- end
- end
+ def serialize_persona(persona)
+ return nil if persona.blank?
- def find_feature_by_id(id)
- lookup = persona_backed_features.index_by { |feature| feature[:id] }
- lookup[id]
+ serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
end
end
end
diff --git a/assets/javascripts/discourse/admin/models/ai-feature.js b/assets/javascripts/discourse/admin/models/ai-feature.js
index 67d9decad..85dfa7ca9 100644
--- a/assets/javascripts/discourse/admin/models/ai-feature.js
+++ b/assets/javascripts/discourse/admin/models/ai-feature.js
@@ -5,6 +5,7 @@ export default class AiFeature extends RestModel {
return this.getProperties(
"id",
"name",
+ "ref",
"description",
"enable_setting",
"persona",
diff --git a/assets/javascripts/discourse/components/ai-feature-editor.gjs b/assets/javascripts/discourse/components/ai-feature-editor.gjs
index dbcb89fd4..6199fe39d 100644
--- a/assets/javascripts/discourse/components/ai-feature-editor.gjs
+++ b/assets/javascripts/discourse/components/ai-feature-editor.gjs
@@ -1,67 +1,13 @@
import Component from "@glimmer/component";
-import { tracked } from "@glimmer/tracking";
-import { action } from "@ember/object";
import { service } from "@ember/service";
-import { htmlSafe } from "@ember/template";
-import { eq } from "truth-helpers";
import BackButton from "discourse/components/back-button";
-import Form from "discourse/components/form";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import getURL from "discourse/lib/get-url";
-import discourseLater from "discourse/lib/later";
-import { i18n } from "discourse-i18n";
+import SiteSettingComponent from "admin/components/site-setting";
export default class AiFeatureEditor extends Component {
@service toasts;
@service currentUser;
@service router;
- @tracked isSaving = false;
-
- get formData() {
- return {
- enabled: this.args.model.enable_setting?.value,
- persona_id: this.args.model.persona?.id,
- };
- }
-
- @action
- async save(formData) {
- this.isSaving = true;
-
- try {
- this.args.model.save({
- enabled: formData.enabled,
- persona_id: parseInt(formData.persona_id, 10),
- });
-
- this.toasts.success({
- data: {
- message: i18n("discourse_ai.features.editor.saved", {
- feature_name: this.args.model.name,
- }),
- },
- duration: 2000,
- });
-
- discourseLater(() => {
- this.router.transitionTo(
- "adminPlugins.show.discourse-ai-features.index"
- );
- }, 500);
- } catch (error) {
- popupAjaxError(error);
- } finally {
- this.isSaving = false;
- }
- }
-
- get personasHint() {
- return i18n("discourse_ai.features.editor.persona_help", {
- config_url: getURL("/admin/plugins/discourse-ai/ai-personas"),
- });
- }
-
{{@model.description}}
-
-
-
- {{/if}}
-
-
-
- {{#each this.currentUser.ai_enabled_personas as |persona|}}
-
- {{persona.name}}
-
- {{/each}}
-
-
-
-
-
-
-
+
+ {{#each @model.feature_settings as |setting|}}
+
+
+
+ {{/each}}
+
}
diff --git a/assets/stylesheets/common/ai-features.scss b/assets/stylesheets/common/ai-features.scss
index 5e1b01602..2ae68c8b5 100644
--- a/assets/stylesheets/common/ai-features.scss
+++ b/assets/stylesheets/common/ai-features.scss
@@ -30,3 +30,36 @@
}
}
}
+
+.ai-feature-editor {
+ &__header {
+ border-bottom: 1px solid var(--primary-low);
+ }
+
+ .setting {
+ margin-block: 1.5rem;
+ }
+
+ .setting-label {
+ font-size: var(--font-down-1-rem);
+ color: var(--primary-high);
+
+ a[title="View change history"],
+ .history-icon {
+ display: none;
+ }
+ }
+
+ .setting-value {
+ .desc {
+ font-size: var(--font-down-1-rem);
+ color: var(--primary-high-or-secondary-low);
+ }
+ }
+
+ .setting-controls,
+ .setting-controls__undo {
+ font-size: var(--font-down-1-rem);
+ margin-top: 0.5rem;
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 8227595a1..1b3da23e6 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -182,6 +182,7 @@ en:
enable_setting_help: "Toggles '%{setting}' setting"
persona: "Persona"
persona_help: "To create/edit personas go to the persona configuration page"
+ advanced_settings: "Advanced settings"
save: "Save"
saved: "%{feature_name} feature saved"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 1b06c5f54..8a74b3505 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -81,11 +81,12 @@ en:
ai_embeddings_semantic_search_hyde_model: "Model used to expand keywords to get better results during a semantic search"
ai_embeddings_per_post_enabled: Generate embeddings for each post
- ai_summarization_enabled: "Enable the topic summarization module."
- ai_summarization_model: "Model to use for summarization."
+ ai_summarization_enabled: "Enable the summarize feature"
+ ai_summarization_model: "Model to use for summarization"
+ ai_summarization_persona: "Persona to use for summarize feature"
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."
ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs."
- ai_summary_gists_enabled: "Generate brief summaries of latest replies in topics automatically."
+ ai_summary_gists_enabled: "Generate brief summaries of latest replies in topics automatically"
ai_summary_gists_allowed_groups: "Groups allowed to see gists in the hot topics list."
ai_summary_backfill_maximum_topics_per_hour: "Number of topic summaries to backfill per hour."
@@ -104,6 +105,13 @@ en:
ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search"
ai_google_custom_search_cx: "CX for Google Custom Search API"
+ ai_discord_search_enabled: "Enables the Discord search feature"
+ ai_discord_app_id: "The ID of the Discord application you would like to connect Discord search to"
+ ai_discord_app_public_key: "The public key of the Discord application you would like to connect Discord search to"
+ ai_discord_search_mode: "Select the search mode to use for Discord search"
+ ai_discord_search_persona: "The persona to use for Discord search."
+ ai_discord_allowed_guilds: "Discord guilds (servers) where the bot is allowed to search"
+
reviewables:
reasons:
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
diff --git a/config/routes.rb b/config/routes.rb
index 2a2d3a16f..c5df0fc7d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -112,7 +112,7 @@
end
resources :ai_features,
- only: %i[index edit update],
+ only: %i[index edit],
path: "ai-features",
controller: "discourse_ai/admin/ai_features"
end
diff --git a/config/settings.yml b/config/settings.yml
index fab3b641d..c8d9fb8dd 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -234,7 +234,7 @@ discourse_ai:
default: false
client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator"
- hidden: true
+ area: "ai-features/summarization"
ai_summarization_model:
default: ""
allow_any: false
@@ -246,11 +246,12 @@ discourse_ai:
default: "-11"
type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator"
- hidden: true
+ area: "ai-features/summarization"
ai_pm_summarization_allowed_groups:
type: group_list
list_type: compact
default: ""
+ area: "ai-features/summarization"
ai_custom_summarization_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
type: group_list
list_type: compact
@@ -258,12 +259,12 @@ discourse_ai:
hidden: true
ai_summary_gists_enabled:
default: false
- hidden: true
+ area: "ai-features/gists"
ai_summary_gists_persona:
default: "-12"
type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator"
- hidden: true
+ area: "ai-features/gists"
ai_summary_gists_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
type: group_list
list_type: compact
@@ -278,17 +279,20 @@ discourse_ai:
default: 30
min: 1
max: 10000
+ area: "ai-features/summarization"
ai_summary_backfill_maximum_topics_per_hour:
default: 0
min: 0
max: 10000
+ area: "ai-features/summarization"
ai_summary_backfill_minimum_word_count:
default: 200
- hidden: true
+ area: "ai-features/summarization"
ai_bot_enabled:
default: false
client: true
+ area: "ai-features/discoveries"
ai_bot_enable_chat_warning:
default: false
client: true
@@ -327,9 +331,9 @@ discourse_ai:
ai_bot_discover_persona:
default: ""
type: enum
- hidden: true
client: true
enum: "DiscourseAi::Configuration::PersonaEnumerator"
+ area: "ai-features/discoveries"
ai_automation_max_triage_per_minute:
default: 60
hidden: true
@@ -345,28 +349,32 @@ discourse_ai:
ai_discord_search_enabled:
default: false
client: true
- hidden: true
+ area: "ai-features/discord_search"
ai_discord_app_id:
default: ""
client: false
+ area: "ai-features/discord_search"
ai_discord_app_public_key:
default: ""
client: false
+ area: "ai-features/discord_search"
ai_discord_search_mode:
default: "search"
type: enum
choices:
- search
- persona
+ area: "ai-features/discord_search"
ai_discord_search_persona:
default: ""
type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator"
- hidden: true
+ area: "ai-features/discord_search"
ai_discord_allowed_guilds:
type: list
list_type: compact
default: ""
+ area: "ai-features/discord_search"
ai_spam_detection_enabled:
default: false
diff --git a/lib/features.rb b/lib/features.rb
new file mode 100644
index 000000000..d3b999c25
--- /dev/null
+++ b/lib/features.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module Features
+ def self.feature_config
+ [
+ {
+ id: 1,
+ name_ref: "summarization",
+ name_key: "discourse_ai.features.summarization.name",
+ description_key: "discourse_ai.features.summarization.description",
+ persona_setting_name: "ai_summarization_persona",
+ enable_setting_name: "ai_summarization_enabled",
+ },
+ {
+ id: 2,
+ name_ref: "gists",
+ name_key: "discourse_ai.features.gists.name",
+ description_key: "discourse_ai.features.gists.description",
+ persona_setting_name: "ai_summary_gists_persona",
+ enable_setting_name: "ai_summary_gists_enabled",
+ },
+ {
+ id: 3,
+ name_ref: "discoveries",
+ name_key: "discourse_ai.features.discoveries.name",
+ description_key: "discourse_ai.features.discoveries.description",
+ persona_setting_name: "ai_bot_discover_persona",
+ enable_setting_name: "ai_bot_enabled",
+ },
+ {
+ id: 4,
+ name_ref: "discord_search",
+ name_key: "discourse_ai.features.discord_search.name",
+ description_key: "discourse_ai.features.discord_search.description",
+ persona_setting_name: "ai_discord_search_persona",
+ enable_setting_name: "ai_discord_search_enabled",
+ },
+ ]
+ end
+
+ def self.features
+ feature_config.map do |feature|
+ {
+ id: feature[:id],
+ ref: feature[:name_ref],
+ name: I18n.t(feature[:name_key]),
+ description: I18n.t(feature[:description_key]),
+ persona: AiPersona.find_by(id: SiteSetting.get(feature[:persona_setting_name])),
+ persona_setting: {
+ name: feature[:persona_setting_name],
+ value: SiteSetting.get(feature[:persona_setting_name]),
+ type: SiteSetting.type_supervisor.get_type(feature[:persona_setting_name]),
+ },
+ enable_setting: {
+ name: feature[:enable_setting_name],
+ value: SiteSetting.get(feature[:enable_setting_name]),
+ type: SiteSetting.type_supervisor.get_type(feature[:enable_setting_name]),
+ },
+ }
+ end
+ end
+
+ def self.find_feature_by_id(id)
+ lookup = features.index_by { |f| f[:id] }
+ lookup[id]
+ end
+
+ def self.find_feature_by_ref(name_ref)
+ lookup = features.index_by { |f| f[:ref] }
+ lookup[name_ref]
+ end
+
+ def self.find_feature_id_by_ref(name_ref)
+ find_feature_by_ref(name_ref)&.dig(:id)
+ end
+
+ def self.feature_area(name_ref)
+ name_ref = name_ref.to_s if name_ref.is_a?(Symbol)
+ find_feature_by_ref(name_ref) || raise(ArgumentError, "Feature not found: #{name_ref}")
+ "ai-features/#{name_ref}"
+ end
+ end
+end
diff --git a/plugin.rb b/plugin.rb
index 552295717..4e2741993 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -70,6 +70,11 @@ def self.public_asset_path(name)
Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::DiscourseAi)
require_relative "lib/engine"
+require_relative "lib/features"
+
+DiscourseAi::Features.feature_config.each do |feature|
+ register_site_setting_area("ai-features/#{feature[:name_ref]}")
+end
after_initialize do
if defined?(Rack::MiniProfiler)
diff --git a/spec/requests/admin/ai_features_controller_spec.rb b/spec/requests/admin/ai_features_controller_spec.rb
index 4e518cefc..8265d856f 100644
--- a/spec/requests/admin/ai_features_controller_spec.rb
+++ b/spec/requests/admin/ai_features_controller_spec.rb
@@ -29,27 +29,4 @@
expect(response.parsed_body["name"]).to eq(I18n.t "discourse_ai.features.summarization.name")
end
end
-
- describe "#update" do
- before do
- SiteSetting.ai_summarization_persona = summarizer_persona.id
- SiteSetting.ai_summarization_enabled = true
- end
-
- it "updates the feature" do
- expect(SiteSetting.ai_summarization_persona).to eq(summarizer_persona.id.to_s)
- expect(SiteSetting.ai_summarization_enabled).to eq(true)
-
- put "/admin/plugins/discourse-ai/ai-features/1.json",
- params: {
- ai_feature: {
- enabled: false,
- persona_id: alternate_summarizer_persona.id,
- },
- }
-
- expect(SiteSetting.ai_summarization_persona).to eq(alternate_summarizer_persona.id.to_s)
- expect(SiteSetting.ai_summarization_enabled).to eq(false)
- end
- end
end
diff --git a/spec/system/admin_ai_features_spec.rb b/spec/system/admin_ai_features_spec.rb
index 92d9266d1..613cd78cd 100644
--- a/spec/system/admin_ai_features_spec.rb
+++ b/spec/system/admin_ai_features_spec.rb
@@ -50,7 +50,7 @@
expect(page).to have_current_path("/admin/plugins")
end
- it "can edit the AI feature" do
+ it "shows edit page with settings" do
ai_features_page.visit
ai_features_page.click_edit_feature(I18n.t("discourse_ai.features.summarization.name"))
expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-features/1/edit")
@@ -59,11 +59,6 @@
text: I18n.t("discourse_ai.features.summarization.name"),
)
- form.field("persona_id").select(-6)
- form.submit
- expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-features")
- expect(ai_features_page).to have_feature_persona(
- I18n.t("discourse_ai.ai_bot.personas.creative.name"),
- )
+ expect(page).to have_css(".setting")
end
end