Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions assets/javascripts/discourse/components/ai-features-list.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,19 @@ class ExpandableList extends Component {
this.isExpanded = !this.isExpanded;
}

@action
isLastItem(index) {
return index === this.visibleItems.length - 1;
}

<template>
{{#each this.visibleItems as |item index|}}
{{yield item index}}
{{yield item index this.isLastItem}}
{{/each}}

{{#if this.hasMore}}
<DButton
class="btn-flat btn-small ai-expanded-list__toggle-button"
class="btn-flat ai-expanded-list__toggle-button"
@translatedLabel={{this.expandToggleLabel}}
@action={{this.toggleExpanded}}
/>
Expand All @@ -61,11 +66,11 @@ class ExpandableList extends Component {

export default class AiFeaturesList extends Component {
get sortedModules() {
return this.args.modules.sort((a, b) => {
const nameA = i18n(`discourse_ai.features.${a.module_name}.name`);
const nameB = i18n(`discourse_ai.features.${b.module_name}.name`);
return nameA.localeCompare(nameB);
});
if (!this.args.modules || !this.args.modules.length) {
return [];
}

return this.args.modules.sortBy("module_name");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one thing to keep mindful of is that sortBy is going to go eventually. @CvX what is the clean and quick way of doing this in a future safe way?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this should do it #1476

}

@action
Expand Down Expand Up @@ -149,63 +154,81 @@ export default class AiFeaturesList extends Component {
{{/unless}}
</div>
<div class="ai-feature-card__persona">
<span>{{i18n
<span class="ai-feature-card__label">
{{i18n
"discourse_ai.features.persona"
count=feature.personas.length
}}</span>
}}
</span>
{{#if feature.personas}}
<ExpandableList
@items={{feature.personas}}
@maxItemsToShow={{5}}
as |persona|
as |persona index isLastItem|
>
<DButton
class="btn-flat btn-small ai-feature-card__persona-button"
@translatedLabel={{persona.name}}
class="btn-flat ai-feature-card__persona-button btn-text"
@translatedLabel={{concat
persona.name
(unless (isLastItem index) ", ")
}}
@route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{persona.id}}
/>
</ExpandableList>
{{else}}
{{i18n "discourse_ai.features.no_persona"}}
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.no_persona"}}
</span>
{{/if}}
</div>
<div class="ai-feature-card__llm">
{{#if feature.llm_models}}
<span>{{i18n
<span class="ai-feature-card__label">
{{i18n
"discourse_ai.features.llm"
count=feature.llm_models.length
}}</span>
}}
</span>
{{/if}}
{{#if feature.llm_models}}
<ExpandableList
@items={{feature.llm_models}}
@maxItemsToShow={{5}}
as |llm|
as |llm index isLastItem|
>
<DButton
class="btn-flat btn-small ai-feature-card__llm-button"
@translatedLabel={{llm.name}}
class="btn-flat ai-feature-card__llm-button"
@translatedLabel={{concat
llm.name
(unless (isLastItem index) ", ")
}}
@route="adminPlugins.show.discourse-ai-llms.edit"
@routeModels={{llm.id}}
/>
</ExpandableList>
{{else}}
{{i18n "discourse_ai.features.no_llm"}}
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.no_llm"}}
</span>
{{/if}}
</div>
{{#unless (this.isSpamModule module)}}
{{#if feature.personas}}
<div class="ai-feature-card__groups">
<span>{{i18n "discourse_ai.features.groups"}}</span>
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.groups"}}
</span>
{{#if (this.hasGroups feature)}}
<ul class="ai-feature-card__item-groups">
{{#each (this.groupList feature) as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
{{else}}
{{i18n "discourse_ai.features.no_groups"}}
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.no_groups"}}
</span>
{{/if}}
</div>
{{/if}}
Expand Down
202 changes: 166 additions & 36 deletions assets/javascripts/discourse/components/ai-features.gjs
Original file line number Diff line number Diff line change
@@ -1,54 +1,170 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { hash } 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 DSelect from "discourse/components/d-select";
import FilterInput from "discourse/components/filter-input";
import { i18n } from "discourse-i18n";
import AiFeaturesList from "./ai-features-list";

const ALL = "all";
const CONFIGURED = "configured";
const UNCONFIGURED = "unconfigured";

export default class AiFeatures extends Component {
@service adminPluginNavManager;

@tracked filterValue = "";
@tracked selectedFeatureGroup = CONFIGURED;

constructor() {
super(...arguments);

if (this.configuredFeatures.length === 0) {
this.selectedFeatureGroup = UNCONFIGURED;
// if there are features but none are configured, show unconfigured
if (this.args.features?.length > 0) {
const configuredCount = this.args.features.filter(
(f) => f.module_enabled === true
).length;
if (configuredCount === 0) {
this.selectedFeatureGroup = UNCONFIGURED;
}
}
}

get featureGroups() {
get featureGroupOptions() {
return [
{ id: CONFIGURED, label: "discourse_ai.features.nav.configured" },
{ id: UNCONFIGURED, label: "discourse_ai.features.nav.unconfigured" },
{ value: ALL, label: i18n("discourse_ai.features.filters.all") },
{
value: CONFIGURED,
label: i18n("discourse_ai.features.nav.configured"),
},
{
value: UNCONFIGURED,
label: i18n("discourse_ai.features.nav.unconfigured"),
},
];
}

get configuredFeatures() {
return this.args.features.filter(
(feature) => feature.module_enabled === true
);
get filteredFeatures() {
if (!this.args.features || this.args.features.length === 0) {
return [];
}

let features = this.args.features;

if (this.selectedFeatureGroup === CONFIGURED) {
features = features.filter((feature) => feature.module_enabled === true);
} else if (this.selectedFeatureGroup === UNCONFIGURED) {
features = features.filter((feature) => feature.module_enabled === false);
}

if (this.filterValue && this.filterValue.trim() !== "") {
const term = this.filterValue.toLowerCase().trim();

const featureMatches = (module, feature) => {
try {
const featureName = i18n(
`discourse_ai.features.${module.module_name}.${feature.name}`
).toLowerCase();
if (featureName.includes(term)) {
return true;
}

const personaMatches = feature.personas?.some((persona) =>
persona.name?.toLowerCase().includes(term)
);

const llmMatches = feature.llm_models?.some((llm) =>
llm.name?.toLowerCase().includes(term)
);

const groupMatches = feature.personas?.some((persona) =>
persona.allowed_groups?.some((group) =>
group.name?.toLowerCase().includes(term)
)
);

return personaMatches || llmMatches || groupMatches;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error filtering features`, error);
return false;
}
};

// Filter modules by name or features
features = features.filter((module) => {
try {
const moduleName = i18n(
`discourse_ai.features.${module.module_name}.name`
).toLowerCase();
if (moduleName.includes(term)) {
return true;
}

return (module.features || []).some((feature) =>
featureMatches(module, feature)
);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error filtering features`, error);
return false;
}
});

// For modules that don't match by name, filter features
features = features
.map((module) => {
try {
const moduleName = i18n(
`discourse_ai.features.${module.module_name}.name`
).toLowerCase();

// if name matches
if (moduleName.includes(term)) {
return module;
}

// if no name match
const matchingFeatures = (module.features || []).filter((feature) =>
featureMatches(module, feature)
);

// recreate with matching features
return Object.assign({}, module, {
features: matchingFeatures,
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error filtering features`, error);
return module;
}
})
.filter((module) => module.features && module.features.length > 0);
}

return features;
}

get unconfiguredFeatures() {
return this.args.features.filter(
(feature) => feature.module_enabled === false
);
@action
onFilterChange(event) {
this.filterValue = event.target?.value || "";
}

@action
selectFeatureGroup(groupId) {
this.selectedFeatureGroup = groupId;
onFeatureGroupChange(value) {
this.selectedFeatureGroup = value;
}

@action
resetAndFocus() {
this.filterValue = "";
this.selectedFeatureGroup = CONFIGURED;
document.querySelector(".admin-filter__input").focus();
}

<template>
Expand All @@ -63,27 +179,41 @@ export default class AiFeatures extends Component {
@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 class="ai-features__controls">
<DSelect
@value={{this.selectedFeatureGroup}}
@includeNone={{false}}
@onChange={{this.onFeatureGroupChange}}
as |select|
>
{{#each this.featureGroupOptions as |option|}}
<select.Option @value={{option.value}}>
{{option.label}}
</select.Option>
{{/each}}
</DSelect>

<FilterInput
placeholder={{i18n "discourse_ai.features.filters.text"}}
@filterAction={{this.onFilterChange}}
@value={{this.filterValue}}
class="admin-filter__input"
@icons={{hash left="magnifying-glass"}}
/>
</div>

{{#if (eq this.selectedFeatureGroup "configured")}}
<AiFeaturesList @modules={{this.configuredFeatures}} />
{{#if this.filteredFeatures.length}}
<AiFeaturesList @modules={{this.filteredFeatures}} />
{{else}}
<AiFeaturesList @modules={{this.unconfiguredFeatures}} />
<div class="ai-features__no-results">
<h3>{{i18n "discourse_ai.features.filters.no_results"}}</h3>
<DButton
@icon="arrow-rotate-left"
@label="discourse_ai.features.filters.reset"
@action={{this.resetAndFocus}}
class="btn-default"
/>
</div>
{{/if}}
</section>
</template>
Expand Down
Loading
Loading