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 new file mode 100644 index 00000000..82e90e74 --- /dev/null +++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features-edit.js @@ -0,0 +1,27 @@ +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.ref}`, + plugin: "discourse-ai", + category: "discourse_ai", + }, + }); + + currentFeature.feature_settings = site_settings.map((setting) => + SiteSetting.create(setting) + ); + + return currentFeature; + } +} diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js new file mode 100644 index 00000000..72b17894 --- /dev/null +++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-features.js @@ -0,0 +1,10 @@ +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"); + } +} diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs new file mode 100644 index 00000000..9513e024 --- /dev/null +++ b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/edit.gjs @@ -0,0 +1,24 @@ +import RouteTemplate from "ember-route-template"; +import BackButton from "discourse/components/back-button"; +import SiteSettingComponent from "admin/components/site-setting"; + +export default RouteTemplate( + +); diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.gjs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.gjs new file mode 100644 index 00000000..9bf9fc9c --- /dev/null +++ b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-features/index.gjs @@ -0,0 +1,156 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import RouteTemplate from "ember-route-template"; +import { gt } from "truth-helpers"; +import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item"; +import DButton from "discourse/components/d-button"; +import DPageSubheader from "discourse/components/d-page-subheader"; +import { i18n } from "discourse-i18n"; + +export default RouteTemplate( + class extends Component { + @service adminPluginNavManager; + + get tableHeaders() { + const prefix = "discourse_ai.features.list.header"; + return [ + i18n(`${prefix}.name`), + i18n(`${prefix}.persona`), + i18n(`${prefix}.groups`), + "", + ]; + } + + get configuredFeatures() { + return this.args.model.filter( + (feature) => feature.enable_setting.value === true + ); + } + + get unconfiguredFeatures() { + return this.args.model.filter( + (feature) => feature.enable_setting.value === false + ); + } + + + } +); diff --git a/app/controllers/discourse_ai/admin/ai_features_controller.rb b/app/controllers/discourse_ai/admin/ai_features_controller.rb new file mode 100644 index 00000000..ad258c6c --- /dev/null +++ b/app/controllers/discourse_ai/admin/ai_features_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module DiscourseAi + module Admin + class AiFeaturesController < ::Admin::AdminController + requires_plugin ::DiscourseAi::PLUGIN_NAME + + def index + render json: serialize_features(DiscourseAi::Features.features) + end + + def edit + raise Discourse::InvalidParameters.new(:id) if params[:id].blank? + render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i)) + end + + private + + def serialize_features(features) + features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) } + end + + def serialize_feature(feature) + return nil if feature.blank? + + feature.merge(persona: serialize_persona(feature[:persona])) + end + + def serialize_persona(persona) + return nil if persona.blank? + + serialize_data(persona, AiFeaturesPersonaSerializer, root: false) + end + end + end +end diff --git a/app/jobs/regular/stream_discord_reply.rb b/app/jobs/regular/stream_discord_reply.rb index 9aca1ef5..7029a080 100644 --- a/app/jobs/regular/stream_discord_reply.rb +++ b/app/jobs/regular/stream_discord_reply.rb @@ -7,6 +7,8 @@ module Jobs def execute(args) interaction = args[:interaction] + return unless SiteSetting.ai_discord_search_enabled + if SiteSetting.ai_discord_search_mode == "persona" DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction! else diff --git a/app/serializers/ai_features_persona_serializer.rb b/app/serializers/ai_features_persona_serializer.rb new file mode 100644 index 00000000..782b5604 --- /dev/null +++ b/app/serializers/ai_features_persona_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AiFeaturesPersonaSerializer < ApplicationSerializer + attributes :id, :name, :system_prompt, :allowed_groups, :enabled + + def allowed_groups + Group + .where(id: object.allowed_group_ids) + .pluck(:id, :name) + .map { |id, name| { id: id, name: name } } + end +end diff --git a/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js index 3e798a18..aa2bed60 100644 --- a/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js +++ b/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js @@ -29,5 +29,9 @@ export default { this.route("edit", { path: "/:id/edit" }); } ); + + this.route("discourse-ai-features", { path: "ai-features" }, function () { + this.route("edit", { path: "/:id/edit" }); + }); }, }; diff --git a/assets/javascripts/discourse/admin/adapters/ai-feature.js b/assets/javascripts/discourse/admin/adapters/ai-feature.js new file mode 100644 index 00000000..994523d1 --- /dev/null +++ b/assets/javascripts/discourse/admin/adapters/ai-feature.js @@ -0,0 +1,21 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default class AiFeatureAdapter extends RestAdapter { + jsonMode = true; + + basePath() { + return "/admin/plugins/discourse-ai/"; + } + + pathFor(store, type, findArgs) { + // removes underscores which are implemented in base + let path = + this.basePath(store, type, findArgs) + + store.pluralize(this.apiNameFor(type)); + return this.appendQueryParams(path, findArgs); + } + + apiNameFor() { + return "ai-feature"; + } +} diff --git a/assets/javascripts/discourse/admin/models/ai-feature.js b/assets/javascripts/discourse/admin/models/ai-feature.js new file mode 100644 index 00000000..85dfa7ca --- /dev/null +++ b/assets/javascripts/discourse/admin/models/ai-feature.js @@ -0,0 +1,15 @@ +import RestModel from "discourse/models/rest"; + +export default class AiFeature extends RestModel { + createProperties() { + return this.getProperties( + "id", + "name", + "ref", + "description", + "enable_setting", + "persona", + "persona_setting" + ); + } +} diff --git a/assets/javascripts/initializers/admin-plugin-configuration-nav.js b/assets/javascripts/initializers/admin-plugin-configuration-nav.js index 1410086c..391f5cbc 100644 --- a/assets/javascripts/initializers/admin-plugin-configuration-nav.js +++ b/assets/javascripts/initializers/admin-plugin-configuration-nav.js @@ -41,6 +41,12 @@ export default { route: "adminPlugins.show.discourse-ai-spam", description: "discourse_ai.spam.spam_description", }, + // TODO(@keegan / @roman): Uncomment this when structured output is merged + // { + // label: "discourse_ai.features.short_title", + // route: "adminPlugins.show.discourse-ai-features", + // description: "discourse_ai.features.description", + // }, ]); }); }, diff --git a/assets/stylesheets/common/ai-features.scss b/assets/stylesheets/common/ai-features.scss new file mode 100644 index 00000000..2ae68c8b --- /dev/null +++ b/assets/stylesheets/common/ai-features.scss @@ -0,0 +1,65 @@ +.ai-feature-list { + &__configured-features { + margin-block: 2rem; + } + + &__row-item-name, + &__row-item-description { + display: block; + } + + &__row-item-persona { + padding: 0; + text-align: left; + + @include ellipsis; + } + + &__row-item-groups { + list-style: none; + margin: 0.5em 0 0 0; + display: flex; + + li { + font-size: var(--font-down-2); + border-radius: var(--d-border-radius); + background: var(--primary-very-low); + border: 1px solid var(--primary-low); + padding: 1px 3px; + margin-right: 0.5em; + } + } +} + +.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 05326ba6..22e9d02c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -165,6 +165,20 @@ en: discourse_ai: title: "AI" + features: + short_title: "Features" + description: "These are the AI features available to visitors on your site. These can be configured to use specific personas and LLMs, and can be access controlled by groups." + back: "Back" + list: + header: + name: "Name" + persona: "Persona" + groups: "Groups" + edit: "Edit" + set_up: "Set up" + configured_features: "Configured features" + unconfigured_features: "Unconfigured features" + modals: select_option: "Select an option..." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2c32001c..8a74b350 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. @@ -487,6 +495,20 @@ en: missing_provider_param: "%{param} can't be blank" bedrock_invalid_url: "Please complete all the fields to use this model." + features: + summarization: + name: "Summaries" + description: "Makes a summarization button available that allows visitors to summarize topics" + gists: + name: "Short Summaries" + description: "Adds the ability to view short summaries of topics on the topic list" + discoveries: + name: "Discobot Discoveries" + description: "Enhances search experience by providing AI-generated answers to queries" + discord_search: + name: "Discord Search" + description: "Adds the ability to search Discord channels" + errors: quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}." quota_required: "You must specify maximum tokens or usages for this model" diff --git a/config/routes.rb b/config/routes.rb index 29fa2658..c5df0fc7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -110,6 +110,11 @@ Discourse::Application.routes.draw do controller: "discourse_ai/admin/ai_embeddings" do collection { get :test } end + + resources :ai_features, + only: %i[index edit], + path: "ai-features", + controller: "discourse_ai/admin/ai_features" end end diff --git a/config/settings.yml b/config/settings.yml index b377d89b..c8d9fb8d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -234,6 +234,7 @@ discourse_ai: default: false client: true validator: "DiscourseAi::Configuration::LlmDependencyValidator" + area: "ai-features/summarization" ai_summarization_model: default: "" allow_any: false @@ -245,11 +246,12 @@ discourse_ai: default: "-11" type: enum enum: "DiscourseAi::Configuration::PersonaEnumerator" - + 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 @@ -257,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 @@ -277,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 @@ -326,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 @@ -341,26 +346,35 @@ discourse_ai: type: list list_type: compact + ai_discord_search_enabled: + default: false + client: 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" + 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/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index 3f9120a3..91abd968 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -105,10 +105,7 @@ module DiscourseAi plugin.add_to_serializer( :current_user, :ai_enabled_personas, - include_condition: -> do - SiteSetting.ai_bot_enabled && scope.authenticated? && - scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map) - end, + include_condition: -> { scope.authenticated? }, ) do DiscourseAi::Personas::Persona .all(user: scope.user) diff --git a/lib/features.rb b/lib/features.rb new file mode 100644 index 00000000..d3b999c2 --- /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 de9fdf5f..e495f250 100644 --- a/plugin.rb +++ b/plugin.rb @@ -27,6 +27,7 @@ enabled_site_setting :discourse_ai_enabled register_asset "stylesheets/common/streaming.scss" register_asset "stylesheets/common/ai-blinking-animation.scss" register_asset "stylesheets/common/ai-user-settings.scss" +register_asset "stylesheets/common/ai-features.scss" register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss" register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop @@ -69,6 +70,11 @@ end 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/jobs/regular/stream_discord_reply_spec.rb b/spec/jobs/regular/stream_discord_reply_spec.rb index c91f1e38..1543bb3f 100644 --- a/spec/jobs/regular/stream_discord_reply_spec.rb +++ b/spec/jobs/regular/stream_discord_reply_spec.rb @@ -17,6 +17,7 @@ RSpec.describe Jobs::StreamDiscordReply, type: :job do fab!(:persona) { Fabricate(:ai_persona, default_llm_id: llm_model.id) } before do + SiteSetting.ai_discord_search_enabled = true SiteSetting.ai_discord_search_mode = "persona" SiteSetting.ai_discord_search_persona = persona.id end diff --git a/spec/requests/admin/ai_features_controller_spec.rb b/spec/requests/admin/ai_features_controller_spec.rb new file mode 100644 index 00000000..8265d856 --- /dev/null +++ b/spec/requests/admin/ai_features_controller_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Admin::AiFeaturesController do + let(:controller) { described_class.new } + fab!(:admin) + fab!(:group) + fab!(:llm_model) + fab!(:summarizer_persona) { Fabricate(:ai_persona) } + fab!(:alternate_summarizer_persona) { Fabricate(:ai_persona) } + + before do + sign_in(admin) + SiteSetting.ai_bot_enabled = true + SiteSetting.discourse_ai_enabled = true + end + + describe "#index" do + it "lists all features backed by personas" do + get "/admin/plugins/discourse-ai/ai-features.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["ai_features"].count).to eq(4) + end + end + + describe "#edit" do + it "returns a success response" do + get "/admin/plugins/discourse-ai/ai-features/1/edit.json" + expect(response.parsed_body["name"]).to eq(I18n.t "discourse_ai.features.summarization.name") + end + end +end diff --git a/spec/serializers/ai_features_persona_serializer_spec.rb b/spec/serializers/ai_features_persona_serializer_spec.rb new file mode 100644 index 00000000..677e2743 --- /dev/null +++ b/spec/serializers/ai_features_persona_serializer_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe AiFeaturesPersonaSerializer do + fab!(:admin) + fab!(:ai_persona) + fab!(:group) + fab!(:group_2) { Fabricate(:group) } + + describe "serialized attributes" do + before do + ai_persona.allowed_group_ids = [group.id, group_2.id] + ai_persona.save! + end + + context "when there is a persona with allowed groups" do + let(:allowed_groups) do + Group + .where(id: ai_persona.allowed_group_ids) + .pluck(:id, :name) + .map { |id, name| { id: id, name: name } } + end + + it "display every participant" do + serialized = described_class.new(ai_persona, scope: Guardian.new(admin), root: nil) + expect(serialized.id).to eq(ai_persona.id) + expect(serialized.name).to eq(ai_persona.name) + expect(serialized.system_prompt).to eq(ai_persona.system_prompt) + expect(serialized.allowed_groups).to eq(allowed_groups) + expect(serialized.enabled).to eq(ai_persona.enabled) + end + end + end +end diff --git a/spec/system/admin_ai_features_spec.rb b/spec/system/admin_ai_features_spec.rb new file mode 100644 index 00000000..613cd78c --- /dev/null +++ b/spec/system/admin_ai_features_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +RSpec.describe "Admin AI features configuration", type: :system, js: true do + fab!(:admin) + fab!(:llm_model) + fab!(:summarization_persona) { Fabricate(:ai_persona) } + fab!(:group_1) { Fabricate(:group) } + fab!(:group_2) { Fabricate(:group) } + let(:page_header) { PageObjects::Components::DPageHeader.new } + let(:form) { PageObjects::Components::FormKit.new("form") } + let(:ai_features_page) { PageObjects::Pages::AdminAiFeatures.new } + + before do + summarization_persona.allowed_group_ids = [group_1.id, group_2.id] + summarization_persona.save! + assign_fake_provider_to(:ai_summarization_model) + SiteSetting.ai_summarization_enabled = true + SiteSetting.ai_summarization_persona = summarization_persona.id + sign_in(admin) + end + + it "lists all persona backed AI features separated by configured/unconfigured" do + ai_features_page.visit + expect( + ai_features_page + .configured_features_table + .find(".ai-feature-list__row-item .ai-feature-list__row-item-name") + .text, + ).to eq(I18n.t("discourse_ai.features.summarization.name")) + + expect(ai_features_page).to have_configured_feature_items(1) + expect(ai_features_page).to have_unconfigured_feature_items(3) + end + + it "lists the persona used for the corresponding AI feature" do + ai_features_page.visit + expect(ai_features_page).to have_feature_persona(summarization_persona.name) + end + + it "lists the groups allowed to use the AI feature" do + ai_features_page.visit + expect(ai_features_page).to have_feature_groups([group_1.name, group_2.name]) + end + + it "can navigate the AI plugin with breadcrumbs" do + visit "/admin/plugins/discourse-ai/ai-features" + expect(page).to have_css(".d-breadcrumbs") + expect(page).to have_css(".d-breadcrumbs__item", count: 4) + find(".d-breadcrumbs__item", text: I18n.t("admin_js.admin.plugins.title")).click + expect(page).to have_current_path("/admin/plugins") + end + + 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") + expect(page).to have_css( + ".ai-feature-editor__header h2", + text: I18n.t("discourse_ai.features.summarization.name"), + ) + + expect(page).to have_css(".setting") + end +end diff --git a/spec/system/page_objects/pages/admin_ai_features.rb b/spec/system/page_objects/pages/admin_ai_features.rb new file mode 100644 index 00000000..e6ad9efd --- /dev/null +++ b/spec/system/page_objects/pages/admin_ai_features.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class AdminAiFeatures < PageObjects::Pages::Base + CONFIGURED_FEATURES_TABLE = ".ai-feature-list__configured-features .d-admin-table" + UNCONFIGURED_FEATURES_TABLE = ".ai-feature-list__unconfigured-features .d-admin-table" + + def visit + page.visit("/admin/plugins/discourse-ai/ai-features") + self + end + + def configured_features_table + page.find(CONFIGURED_FEATURES_TABLE) + end + + def unconfigured_features_table + page.find(UNCONFIGURED_FEATURES_TABLE) + end + + def has_configured_feature_items?(count) + page.has_css?("#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__row", count: count) + end + + def has_unconfigured_feature_items?(count) + page.has_css?("#{UNCONFIGURED_FEATURES_TABLE} .ai-feature-list__row", count: count) + end + + def has_feature_persona?(name) + page.has_css?( + "#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__persona .d-button-label ", + text: name, + ) + end + + def has_feature_groups?(groups) + listed_groups = page.find("#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__groups") + list_items = listed_groups.all("li", visible: true).map(&:text) + + list_items.sort == groups.sort + end + + def click_edit_feature(feature_name) + page.find( + "#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__row[data-feature-name='#{feature_name}'] .edit", + ).click + end + end + end +end