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"), - }); - } - } 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