FEATURE: Display features that rely on multiple personas. (#1411)

* FEATURE: Display features that rely on multiple personas.

This change makes the previously hidden feature page visible while displaying features, like the AI helper, which relies on multiple personas.

* Fix system specs
This commit is contained in:
Roman Rizzi 2025-06-09 16:13:09 -03:00 committed by GitHub
parent 33fd6801e5
commit 98afd7f8c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 412 additions and 335 deletions

View File

@ -12,7 +12,7 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
const { site_settings } = await ajax("/admin/config/site_settings.json", { const { site_settings } = await ajax("/admin/config/site_settings.json", {
data: { data: {
filter_area: `ai-features/${currentFeature.ref}`, filter_area: `ai-features/${currentFeature.module_name}`,
plugin: "discourse-ai", plugin: "discourse-ai",
category: "discourse_ai", category: "discourse_ai",
}, },

View File

@ -1,156 +0,0 @@
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
);
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-features"
@label={{i18n "discourse_ai.features.short_title"}}
/>
<section class="ai-feature-list admin-detail">
<DPageSubheader
@titleLabel={{i18n "discourse_ai.features.short_title"}}
@descriptionLabel={{i18n "discourse_ai.features.description"}}
@learnMoreUrl="todo"
/>
{{#if (gt this.configuredFeatures.length 0)}}
<div class="ai-feature-list__configured-features">
<h3>{{i18n "discourse_ai.features.list.configured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
{{#each this.tableHeaders as |header|}}
<th>{{header}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.configuredFeatures as |feature|}}
<tr
class="ai-feature-list__row d-admin-row__content"
data-feature-name={{feature.name}}
>
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__persona"
>
<DButton
class="btn-flat btn-small ai-feature-list__row-item-persona"
@translatedLabel={{feature.persona.name}}
@route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{feature.persona.id}}
/>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
>
{{#if (gt feature.persona.allowed_groups.length 0)}}
<ul class="ai-feature-list__row-item-groups">
{{#each feature.persona.allowed_groups as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
{{/if}}
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small edit"
@label="discourse_ai.features.list.edit"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{#if (gt this.unconfiguredFeatures.length 0)}}
<div class="ai-feature-list__unconfigured-features">
<h3>{{i18n "discourse_ai.features.list.unconfigured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.features.list.header.name"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.unconfiguredFeatures as |feature|}}
<tr class="ai-feature-list__row d-admin-row__content">
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small"
@label="discourse_ai.features.list.set_up"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</section>
</template>
}
);

View File

@ -0,0 +1 @@
<AiFeatures @features={{this.model}} />

View File

@ -11,19 +11,22 @@ module DiscourseAi
def edit def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank? raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i)) render json: serialize_module(DiscourseAi::Features.find_module_by_id(params[:id].to_i))
end end
private private
def serialize_features(features) def serialize_features(modules)
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) } modules.map { |a_module| serialize_module(a_module) }
end end
def serialize_feature(feature) def serialize_module(a_module)
return nil if feature.blank? return nil if a_module.blank?
feature.merge(persona: serialize_persona(feature[:persona])) a_module.merge(
features:
a_module[:features].map { |f| f.merge(persona: serialize_persona(f[:persona])) },
)
end end
def serialize_persona(persona) def serialize_persona(persona)

View File

@ -2,14 +2,6 @@ import RestModel from "discourse/models/rest";
export default class AiFeature extends RestModel { export default class AiFeature extends RestModel {
createProperties() { createProperties() {
return this.getProperties( return this.getProperties("id", "module", "global_enabled", "features");
"id",
"name",
"ref",
"description",
"enable_setting",
"persona",
"persona_setting"
);
} }
} }

View File

@ -0,0 +1,85 @@
import { concat } from "@ember/helper";
import { gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import { i18n } from "discourse-i18n";
const AiFeaturesList = <template>
<div class="ai-features-list">
{{#each @modules as |module|}}
<div class="ai-module" data-module-name={{module.module_name}}>
<div class="ai-module__header">
<div class="ai-module__module-title">
<h3>{{i18n
(concat "discourse_ai.features." module.module_name ".name")
}}</h3>
<DButton
class="edit"
@label="discourse_ai.features.edit"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{module.id}}
/>
</div>
<div>{{i18n
(concat
"discourse_ai.features." module.module_name ".description"
)
}}</div>
</div>
<div class="admin-section-landing-wrapper ai-feature-cards">
{{#each module.features as |feature|}}
<div
class="admin-section-landing-item ai-feature-card"
data-feature-name={{feature.name}}
>
<div class="admin-section-landing-item__content">
<div class="ai-feature-card__feature-name">
{{i18n
(concat
"discourse_ai.features."
module.module_name
"."
feature.name
)
}}
{{#unless feature.enabled}}
<span>{{i18n "discourse_ai.features.disabled"}}</span>
{{/unless}}
</div>
<div class="ai-feature-card__persona">
<span>{{i18n "discourse_ai.features.persona"}}</span>
{{#if feature.persona}}
<DButton
class="btn-flat btn-small ai-feature-card__persona-button"
@translatedLabel={{feature.persona.name}}
@route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{feature.persona.id}}
/>
{{else}}
{{i18n "discourse_ai.features.no_persona"}}
{{/if}}
</div>
{{#if feature.persona}}
<div class="ai-feature-card__groups">
<span>{{i18n "discourse_ai.features.groups"}}</span>
{{#if (gt feature.persona.allowed_groups.length 0)}}
<ul class="ai-feature-card__item-groups">
{{#each feature.persona.allowed_groups as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
{{else}}
{{i18n "discourse_ai.features.no_groups"}}
{{/if}}
</div>
{{/if}}
</div>
</div>
{{/each}}
</div>
</div>
{{/each}}
</div>
</template>;
export default AiFeaturesList;

View File

@ -0,0 +1,90 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } 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 concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
import AiFeaturesList from "./ai-features-list";
const CONFIGURED = "configured";
const UNCONFIGURED = "unconfigured";
export default class AiFeatures extends Component {
@service adminPluginNavManager;
@tracked selectedFeatureGroup = CONFIGURED;
constructor() {
super(...arguments);
if (this.configuredFeatures.length === 0) {
this.selectedFeatureGroup = UNCONFIGURED;
}
}
get featureGroups() {
return [
{ id: CONFIGURED, label: "discourse_ai.features.nav.configured" },
{ id: UNCONFIGURED, label: "discourse_ai.features.nav.unconfigured" },
];
}
get configuredFeatures() {
return this.args.features.filter(
(feature) => feature.module_enabled === true
);
}
get unconfiguredFeatures() {
return this.args.features.filter(
(feature) => feature.module_enabled === false
);
}
@action
selectFeatureGroup(groupId) {
this.selectedFeatureGroup = groupId;
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-features"
@label={{i18n "discourse_ai.features.short_title"}}
/>
<section class="ai-features admin-detail">
<DPageSubheader
@titleLabel={{i18n "discourse_ai.features.short_title"}}
@descriptionLabel={{i18n "discourse_ai.features.description"}}
@learnMoreUrl="todo"
/>
<div class="ai-feature-groups">
{{#each this.featureGroups as |groupData|}}
<DButton
class={{concatClass
groupData.id
(if
(eq this.selectedFeatureGroup groupData.id)
"btn-primary"
"btn-default"
)
}}
@action={{fn this.selectFeatureGroup groupData.id}}
@label={{groupData.label}}
/>
{{/each}}
</div>
{{#if (eq this.selectedFeatureGroup "configured")}}
<AiFeaturesList @modules={{this.configuredFeatures}} />
{{else}}
<AiFeaturesList @modules={{this.unconfiguredFeatures}} />
{{/if}}
</section>
</template>
}

View File

@ -41,12 +41,11 @@ export default {
route: "adminPlugins.show.discourse-ai-spam", route: "adminPlugins.show.discourse-ai-spam",
description: "discourse_ai.spam.spam_description", description: "discourse_ai.spam.spam_description",
}, },
// TODO(@keegan / @roman): Uncomment this when structured output is merged {
// { label: "discourse_ai.features.short_title",
// label: "discourse_ai.features.short_title", route: "adminPlugins.show.discourse-ai-features",
// route: "adminPlugins.show.discourse-ai-features", description: "discourse_ai.features.description",
// description: "discourse_ai.features.description", },
// },
]); ]);
}); });
}, },

View File

@ -1,28 +1,51 @@
.ai-feature-list { .ai-features-list {
&__configured-features {
margin-block: 2rem; margin-block: 2rem;
}
.ai-module {
&__header {
border-bottom: 1px solid var(--primary-low);
padding-bottom: 0.5rem;
} }
&__row-item-name, &__module-title {
&__row-item-description { display: flex;
justify-content: space-between;
}
}
.ai-feature-cards {
margin-top: 0.5rem;
}
.ai-feature-card {
background: var(--primary-very-low);
border: 1px solid var(--primary-low);
padding: 0.5rem;
display: block; display: block;
&__persona,
&__groups {
font-size: var(--font-down-1-rem);
} }
&__row-item-persona { &__persona {
padding: 0;
text-align: left;
@include ellipsis; @include ellipsis;
} }
&__row-item-groups { &__persona-button {
padding-left: 0;
}
&__item-groups {
list-style: none; list-style: none;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
gap: 0.25em; gap: 0.25em;
margin: 0.5em 0;
li { li {
font-size: var(--font-down-2); font-size: var(--font-down-1);
border-radius: var(--d-border-radius); border-radius: var(--d-border-radius);
background: var(--primary-very-low); background: var(--primary-very-low);
border: 1px solid var(--primary-low); border: 1px solid var(--primary-low);

View File

@ -169,15 +169,48 @@ en:
short_title: "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." 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" back: "Back"
list: disabled: "(disabled)"
header: persona: "Persona:"
name: "Name" groups: "Groups:"
persona: "Persona" no_persona: "Not set"
groups: "Groups" no_groups: "None"
edit: "Edit" edit: "Edit"
set_up: "Set up" nav:
configured_features: "Configured features" configured: "Configured"
unconfigured_features: "Unconfigured features" unconfigured: "Unconfigured"
summarization:
name: "Summaries"
description: "Makes a summarization button available that allows visitors to summarize topics"
topic_summaries: "Topic summaries"
gists: "Topic list's short summaries"
search:
name: "Search"
description: "Enhances search experience by providing AI-generated answers to queries"
discoveries: "Discoveries"
discord:
name: "Discord integration"
description: "Adds the ability to search Discord channels"
search: "Discord search"
inference:
name: "Inferred concepts"
description: "Classifies topics and posts into areas of interest / labels."
generate_concepts: "Concepts inference"
match_concepts: "Concepts matching"
deduplicate_concepts: "Concepts deduplication"
ai_helper:
name: "Helper"
description: "Assists users in community interaction, such as creating topics, writing posts, and reading content."
proofread: Proofread text
title_suggestions: "Suggest titles"
explain: "Explain"
illustrate_post: "Illustrate post"
smart_dates: "Smart dates"
translate: "Translate"
markdown_tables: "Generate Markdown table"
custom_prompt: "Custom prompt"
image_caption: "Caption images"
modals: modals:
select_option: "Select an option..." select_option: "Select an option..."

View File

@ -579,23 +579,6 @@ en:
missing_provider_param: "%{param} can't be blank" missing_provider_param: "%{param} can't be blank"
bedrock_invalid_url: "Please complete all the fields to use this model." 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"
inferred_concepts:
name: "Inferred Concepts"
description: "Classifies topics and posts into areas of interest / labels."
errors: errors:
quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}." 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" quota_required: "You must specify maximum tokens or usages for this model"

View File

@ -90,15 +90,18 @@ discourse_ai:
default: false default: false
client: true client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator" validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/ai_helper"
composer_ai_helper_allowed_groups: composer_ai_helper_allowed_groups:
type: group_list type: group_list
list_type: compact list_type: compact
default: "3|14" # 3: @staff, 14: @trust_level_4 default: "3|14" # 3: @staff, 14: @trust_level_4
allow_any: false allow_any: false
refresh: true refresh: true
area: "ai-features/ai_helper"
ai_helper_allowed_in_pm: ai_helper_allowed_in_pm:
default: false default: false
client: true client: true
area: "ai-features/ai_helper"
ai_helper_model: ai_helper_model:
default: "" default: ""
allow_any: false allow_any: false
@ -118,10 +121,13 @@ discourse_ai:
default: "3|14" # 3: @staff, 14: @trust_level_4 default: "3|14" # 3: @staff, 14: @trust_level_4
allow_any: false allow_any: false
refresh: true refresh: true
area: "ai-features/ai_helper"
ai_helper_automatic_chat_thread_title: ai_helper_automatic_chat_thread_title:
default: false default: false
area: "ai-features/ai_helper"
ai_helper_automatic_chat_thread_title_delay: ai_helper_automatic_chat_thread_title_delay:
default: 5 default: 5
area: "ai-features/ai_helper"
ai_helper_illustrate_post_model: ai_helper_illustrate_post_model:
default: disabled default: disabled
type: enum type: enum
@ -129,6 +135,7 @@ discourse_ai:
- stable_diffusion_xl - stable_diffusion_xl
- dall_e_3 - dall_e_3
- disabled - disabled
area: "ai-features/ai_helper"
ai_helper_enabled_features: ai_helper_enabled_features:
client: true client: true
default: "suggestions|context_menu" default: "suggestions|context_menu"
@ -140,6 +147,7 @@ discourse_ai:
- "suggestions" - "suggestions"
- "context_menu" - "context_menu"
- "image_caption" - "image_caption"
area: "ai-features/ai_helper"
ai_helper_image_caption_model: ai_helper_image_caption_model:
default: "" default: ""
type: enum type: enum
@ -152,6 +160,7 @@ discourse_ai:
default: "10" # 10: @trust_level_0 default: "10" # 10: @trust_level_0
allow_any: false allow_any: false
refresh: true refresh: true
area: "ai-features/ai_helper"
ai_helper_model_allowed_seeded_models: ai_helper_model_allowed_seeded_models:
default: "" default: ""
hidden: true hidden: true
@ -166,38 +175,47 @@ discourse_ai:
default: "-22" default: "-22"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_title_suggestions_persona: ai_helper_title_suggestions_persona:
default: "-23" default: "-23"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_explain_persona: ai_helper_explain_persona:
default: "-24" default: "-24"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_post_illustrator_persona: ai_helper_post_illustrator_persona:
default: "-21" default: "-21"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_smart_dates_persona: ai_helper_smart_dates_persona:
default: "-19" default: "-19"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_translator_persona: ai_helper_translator_persona:
default: "-25" default: "-25"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_markdown_tables_persona: ai_helper_markdown_tables_persona:
default: "-20" default: "-20"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_custom_prompt_persona: ai_helper_custom_prompt_persona:
default: "-18" default: "-18"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_helper_image_caption_persona: ai_helper_image_caption_persona:
default: "-26" default: "-26"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/ai_helper"
ai_embeddings_enabled: ai_embeddings_enabled:
default: false default: false
@ -298,12 +316,12 @@ discourse_ai:
hidden: true hidden: true
ai_summary_gists_enabled: ai_summary_gists_enabled:
default: false default: false
area: "ai-features/gists" area: "ai-features/summarization"
ai_summary_gists_persona: ai_summary_gists_persona:
default: "-12" default: "-12"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/gists" area: "ai-features/summarization"
ai_summary_gists_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01 ai_summary_gists_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
type: group_list type: group_list
list_type: compact list_type: compact
@ -331,7 +349,7 @@ discourse_ai:
ai_bot_enabled: ai_bot_enabled:
default: false default: false
client: true client: true
area: "ai-features/discoveries" area: "ai-features/search"
ai_bot_enable_chat_warning: ai_bot_enable_chat_warning:
default: false default: false
client: true client: true
@ -367,7 +385,7 @@ discourse_ai:
type: enum type: enum
client: true client: true
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/discoveries" area: "ai-features/search"
ai_automation_max_triage_per_minute: ai_automation_max_triage_per_minute:
default: 60 default: 60
hidden: true hidden: true
@ -383,32 +401,32 @@ discourse_ai:
ai_discord_search_enabled: ai_discord_search_enabled:
default: false default: false
client: true client: true
area: "ai-features/discord_search" area: "ai-features/discord"
ai_discord_app_id: ai_discord_app_id:
default: "" default: ""
client: false client: false
area: "ai-features/discord_search" area: "ai-features/discord"
ai_discord_app_public_key: ai_discord_app_public_key:
default: "" default: ""
client: false client: false
area: "ai-features/discord_search" area: "ai-features/discord"
ai_discord_search_mode: ai_discord_search_mode:
default: "search" default: "search"
type: enum type: enum
choices: choices:
- search - search
- persona - persona
area: "ai-features/discord_search" area: "ai-features/discord"
ai_discord_search_persona: ai_discord_search_persona:
default: "" default: ""
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/discord_search" area: "ai-features/discord"
ai_discord_allowed_guilds: ai_discord_allowed_guilds:
type: list type: list
list_type: compact list_type: compact
default: "" default: ""
area: "ai-features/discord_search" area: "ai-features/discord"
ai_spam_detection_enabled: ai_spam_detection_enabled:
default: false default: false
@ -459,51 +477,51 @@ discourse_ai:
inferred_concepts_enabled: inferred_concepts_enabled:
default: false default: false
client: true client: true
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_background_match: inferred_concepts_background_match:
default: false default: false
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_daily_topics_limit: inferred_concepts_daily_topics_limit:
default: 20 default: 20
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_min_posts: inferred_concepts_min_posts:
default: 5 default: 5
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_min_likes: inferred_concepts_min_likes:
default: 10 default: 10
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_min_views: inferred_concepts_min_views:
default: 100 default: 100
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_lookback_days: inferred_concepts_lookback_days:
default: 30 default: 30
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_daily_posts_limit: inferred_concepts_daily_posts_limit:
default: 30 default: 30
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_post_min_likes: inferred_concepts_post_min_likes:
default: 5 default: 5
client: false client: false
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_generate_persona: inferred_concepts_generate_persona:
default: "-15" default: "-15"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_match_persona: inferred_concepts_match_persona:
default: "-16" default: "-16"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/inferred_concepts" area: "ai-features/inference"
inferred_concepts_deduplicate_persona: inferred_concepts_deduplicate_persona:
default: "-17" default: "-17"
type: enum type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator" enum: "DiscourseAi::Configuration::PersonaEnumerator"
area: "ai-features/inferred_concepts" area: "ai-features/inference"

View File

@ -2,91 +2,107 @@
module DiscourseAi module DiscourseAi
module Features module Features
def self.feature_config def self.features_config
[ [
{ {
id: 1, id: 1,
name_ref: "summarization", module_name: "summarization",
name_key: "discourse_ai.features.summarization.name", module_enabled: "ai_summarization_enabled",
description_key: "discourse_ai.features.summarization.description", features: [
persona_setting_name: "ai_summarization_persona", { name: "topic_summaries", persona_setting_name: "ai_summarization_persona" },
enable_setting_name: "ai_summarization_enabled", {
name: "gists",
persona_setting_name: "ai_summary_gists_persona",
enabled: "ai_summary_gists_enabled",
},
],
}, },
{ {
id: 2, id: 2,
name_ref: "gists", module_name: "search",
name_key: "discourse_ai.features.gists.name", module_enabled: "ai_bot_enabled",
description_key: "discourse_ai.features.gists.description", features: [{ name: "discoveries", persona_setting_name: "ai_bot_discover_persona" }],
persona_setting_name: "ai_summary_gists_persona",
enable_setting_name: "ai_summary_gists_enabled",
}, },
{ {
id: 3, id: 3,
name_ref: "discoveries", module_name: "discord",
name_key: "discourse_ai.features.discoveries.name", module_enabled: "ai_discord_search_enabled",
description_key: "discourse_ai.features.discoveries.description", features: [{ name: "search", persona_setting_name: "ai_discord_search_persona" }],
persona_setting_name: "ai_bot_discover_persona",
enable_setting_name: "ai_bot_enabled",
}, },
{ {
id: 4, id: 4,
name_ref: "discord_search", module_name: "inference",
name_key: "discourse_ai.features.discord_search.name", module_enabled: "inferred_concepts_enabled",
description_key: "discourse_ai.features.discord_search.description", features: [
persona_setting_name: "ai_discord_search_persona", {
enable_setting_name: "ai_discord_search_enabled", name: "generate_concepts",
persona_setting_name: "inferred_concepts_generate_persona",
},
{ name: "match_concepts", persona_setting_name: "inferred_concepts_match_persona" },
{
name: "deduplicate_concepts",
persona_setting_name: "inferred_concepts_deduplicate_persona",
},
],
}, },
{ {
id: 5, id: 5,
name_ref: "inferred_concepts", module_name: "ai_helper",
name_key: "discourse_ai.features.inferred_concepts.name", module_enabled: "ai_helper_enabled",
description_key: "discourse_ai.features.inferred_concepts.description", features: [
persona_setting_name: "inferred_concepts_generate_persona", { name: "proofread", persona_setting_name: "ai_helper_proofreader_persona" },
enable_setting_name: "inferred_concepts_enabled", {
name: "title_suggestions",
persona_setting_name: "ai_helper_title_suggestions_persona",
},
{ name: "explain", persona_setting_name: "ai_helper_explain_persona" },
{ name: "illustrate_post", persona_setting_name: "ai_helper_post_illustrator_persona" },
{ name: "smart_dates", persona_setting_name: "ai_helper_smart_dates_persona" },
{ name: "translate", persona_setting_name: "ai_helper_translator_persona" },
{ name: "markdown_tables", persona_setting_name: "ai_helper_markdown_tables_persona" },
{ name: "custom_prompt", persona_setting_name: "ai_helper_custom_prompt_persona" },
{ name: "image_caption", persona_setting_name: "ai_helper_image_caption_persona" },
],
}, },
] ]
end end
def self.features def self.features
feature_config.map do |feature| features_config.map do |a_module|
{ {
id: feature[:id], id: a_module[:id],
ref: feature[:name_ref], module_name: a_module[:module_name],
name: I18n.t(feature[:name_key]), module_enabled: SiteSetting.get(a_module[:module_enabled]),
description: I18n.t(feature[:description_key]), features:
a_module[:features].map do |feature|
{
name: feature[:name],
persona: AiPersona.find_by(id: SiteSetting.get(feature[:persona_setting_name])), persona: AiPersona.find_by(id: SiteSetting.get(feature[:persona_setting_name])),
persona_setting: { enabled: feature[:enabled].present? ? SiteSetting.get(feature[:enabled]) : true,
name: feature[:persona_setting_name], }
value: SiteSetting.get(feature[:persona_setting_name]), end,
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
end end
def self.find_feature_by_id(id) def self.find_module_by_id(id)
lookup = features.index_by { |f| f[:id] } lookup = features.index_by { |f| f[:id] }
lookup[id] lookup[id]
end end
def self.find_feature_by_ref(name_ref) def self.find_module_by_name(module_name)
lookup = features.index_by { |f| f[:ref] } lookup = features.index_by { |f| f[:module] }
lookup[name_ref] lookup[module_name]
end end
def self.find_feature_id_by_ref(name_ref) def self.find_module_id_by_name(module_name)
find_feature_by_ref(name_ref)&.dig(:id) find_module_by_name(module_name)&.dig(:id)
end end
def self.feature_area(name_ref) def self.feature_area(module_name)
name_ref = name_ref.to_s if name_ref.is_a?(Symbol) name_s = module_name.to_s
find_feature_by_ref(name_ref) || raise(ArgumentError, "Feature not found: #{name_ref}") find_module_by_name(name_s) || raise(ArgumentError, "Feature not found: #{name_s}")
"ai-features/#{name_ref}" "ai-features/#{name_s}"
end end
end end
end end

View File

@ -74,8 +74,8 @@ Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::Discours
require_relative "lib/engine" require_relative "lib/engine"
require_relative "lib/features" require_relative "lib/features"
DiscourseAi::Features.feature_config.each do |feature| DiscourseAi::Features.features_config.each do |feature|
register_site_setting_area("ai-features/#{feature[:name_ref]}") register_site_setting_area("ai-features/#{feature[:module_name]}")
end end
after_initialize do after_initialize do

View File

@ -26,7 +26,7 @@ RSpec.describe DiscourseAi::Admin::AiFeaturesController do
describe "#edit" do describe "#edit" do
it "returns a success response" do it "returns a success response" do
get "/admin/plugins/discourse-ai/ai-features/1/edit.json" get "/admin/plugins/discourse-ai/ai-features/1/edit.json"
expect(response.parsed_body["name"]).to eq(I18n.t "discourse_ai.features.summarization.name") expect(response.parsed_body["module_name"]).to eq("summarization")
end end
end end
end end

View File

@ -21,43 +21,37 @@ RSpec.describe "Admin AI features configuration", type: :system, js: true do
it "lists all persona backed AI features separated by configured/unconfigured" do it "lists all persona backed AI features separated by configured/unconfigured" do
ai_features_page.visit ai_features_page.visit
expect( ai_features_page.toggle_configured
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_listed_modules(1)
expect(ai_features_page).to have_unconfigured_feature_items(4)
ai_features_page.toggle_unconfigured
expect(ai_features_page).to have_listed_modules(4)
end end
it "lists the persona used for the corresponding AI feature" do it "lists the persona used for the corresponding AI feature" do
ai_features_page.visit ai_features_page.visit
expect(ai_features_page).to have_feature_persona(summarization_persona.name)
ai_features_page.toggle_configured
expect(ai_features_page).to have_feature_persona("topic_summaries", summarization_persona.name)
end end
it "lists the groups allowed to use the AI feature" do it "lists the groups allowed to use the AI feature" do
ai_features_page.visit 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 ai_features_page.toggle_configured
visit "/admin/plugins/discourse-ai/ai-features"
expect(page).to have_css(".d-breadcrumbs") expect(ai_features_page).to have_feature_groups("topic_summaries", [group_1.name, group_2.name])
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 end
it "shows edit page with settings" do it "shows edit page with settings" do
ai_features_page.visit ai_features_page.visit
ai_features_page.click_edit_feature(I18n.t("discourse_ai.features.summarization.name"))
ai_features_page.click_edit_module("summarization")
expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-features/1/edit") 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") expect(page).to have_css(".setting")
end end

View File

@ -3,48 +3,44 @@
module PageObjects module PageObjects
module Pages module Pages
class AdminAiFeatures < PageObjects::Pages::Base class AdminAiFeatures < PageObjects::Pages::Base
CONFIGURED_FEATURES_TABLE = ".ai-feature-list__configured-features .d-admin-table" FEATURES_PAGE = ".ai-features"
UNCONFIGURED_FEATURES_TABLE = ".ai-feature-list__unconfigured-features .d-admin-table"
def visit def visit
page.visit("/admin/plugins/discourse-ai/ai-features") page.visit("/admin/plugins/discourse-ai/ai-features")
self self
end end
def configured_features_table def toggle_configured
page.find(CONFIGURED_FEATURES_TABLE) page.find("#{FEATURES_PAGE} .ai-feature-groups .configured").click
end end
def unconfigured_features_table def toggle_unconfigured
page.find(UNCONFIGURED_FEATURES_TABLE) page.find("#{FEATURES_PAGE} .ai-feature-groups .unconfigured").click
end end
def has_configured_feature_items?(count) def has_listed_modules?(count)
page.has_css?("#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__row", count: count) page.has_css?("#{FEATURES_PAGE} .ai-module", count: count)
end end
def has_unconfigured_feature_items?(count) def has_feature_persona?(feature_name, name)
page.has_css?("#{UNCONFIGURED_FEATURES_TABLE} .ai-feature-list__row", count: count)
end
def has_feature_persona?(name)
page.has_css?( page.has_css?(
"#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__persona .d-button-label ", "#{FEATURES_PAGE} .ai-feature-card[data-feature-name='#{feature_name}'] .ai-feature-card__persona-button .d-button-label",
text: name, text: name,
) )
end end
def has_feature_groups?(groups) def has_feature_groups?(feature_name, groups)
listed_groups = page.find("#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__groups") listed_groups =
page.find(
"#{FEATURES_PAGE} .ai-feature-card[data-feature-name='#{feature_name}'] .ai-feature-card__item-groups",
)
list_items = listed_groups.all("li", visible: true).map(&:text) list_items = listed_groups.all("li", visible: true).map(&:text)
list_items.sort == groups.sort list_items.sort == groups.sort
end end
def click_edit_feature(feature_name) def click_edit_module(module_name)
page.find( page.find("#{FEATURES_PAGE} .ai-module[data-module-name='#{module_name}'] .edit").click
"#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__row[data-feature-name='#{feature_name}'] .edit",
).click
end end
end end
end end