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 6 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
13 changes: 8 additions & 5 deletions app/controllers/discourse_ai/admin/ai_personas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ class AiPersonasController < ::Admin::AdminController

def index
ai_personas =
AiPersona.ordered.map do |persona|
# we use a special serializer here cause names and descriptions are
# localized for system personas
LocalizedAiPersonaSerializer.new(persona, root: false)
end
AiPersona
.ordered
.includes(:user, :uploads)
.map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) }

tools =
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end

AiTool
.where(enabled: true)
.each do |tool|
Expand All @@ -31,10 +32,12 @@ def index
),
}
end

llms =
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
)

render json: {
ai_personas: ai_personas,
meta: {
Expand Down
9 changes: 8 additions & 1 deletion app/serializers/localized_ai_persona_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
:allow_personal_messages,
:force_default_llm,
:response_format,
:examples
:examples,
:features

has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
Expand All @@ -53,4 +54,10 @@ def description
def default_llm
LlmModel.find_by(id: object.default_llm_id)
end

def features
object.features.map do |feature|
{ id: feature.module_id, module_name: feature.module_name, name: feature.name }
end
end
end
254 changes: 229 additions & 25 deletions assets/javascripts/discourse/components/ai-persona-list-editor.gjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,109 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { tracked } from "@glimmer/tracking";
import { concat, fn, hash } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import DSelect from "discourse/components/d-select";
import DropdownMenu from "discourse/components/dropdown-menu";
import FilterInput from "discourse/components/filter-input";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import DMenu from "float-kit/components/d-menu";
import AiPersonaEditor from "./ai-persona-editor";

const LAYOUT_BUTTONS = [
{
id: "table",
label: "discourse_ai.layout.table",
icon: "discourse-table",
},
{
id: "card",
label: "discourse_ai.layout.card",
icon: "table",
},
];

export default class AiPersonaListEditor extends Component {
@service adminPluginNavManager;
@service keyValueStore;
@service capabilities;

@tracked filterValue = "";
@tracked featureFilter = "all";
@tracked currentLayout = LAYOUT_BUTTONS[0];

constructor() {
super(...arguments);
const savedLayoutId = this.keyValueStore.get("ai-persona-list-layout");
if (savedLayoutId) {
const found = LAYOUT_BUTTONS.find((b) => b.id === savedLayoutId);
if (found) {
this.currentLayout = found;
}
}
}

get filteredPersonas() {
let personas = this.args.personas || [];

// Filter by feature if not "all"
if (this.featureFilter !== "all") {
personas = personas.filter((persona) =>
(persona.features || []).some(
(feature) => feature.module_name === this.featureFilter
)
);
}

// Filter by search term if present
if (this.filterValue) {
const term = this.filterValue.toLowerCase();
personas = personas.filter((persona) => {
const textMatches =
persona.name?.toLowerCase().includes(term) ||
persona.description?.toLowerCase().includes(term);

const featureMatches = (persona.features || []).some((feature) =>
feature.name?.toLowerCase().includes(term)
);

return textMatches || featureMatches;
});
}

return personas;
}

get featureFilterOptions() {
let features = [];
(this.args.personas || []).forEach((persona) => {
(persona.features || []).forEach((feature) => {
if (feature?.module_name && !features.includes(feature.module_name)) {
features.push(feature.module_name);
}
});
});
features.sort();
return [
{
value: "all",
label: i18n("discourse_ai.ai_persona.filters.all_features"),
},
...features.map((module_name) => ({
value: module_name,
label: i18n(`discourse_ai.features.${module_name}.name`),
})),
];
}

@action
async toggleEnabled(persona) {
Expand All @@ -30,12 +119,47 @@ export default class AiPersonaListEditor extends Component {
}
}

@action
onNameFilterChange(event) {
this.filterValue = event.target?.value || "";
}

@action
onFeatureFilterChange(value) {
this.featureFilter = value;
}

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

@action
onRegisterApi(api) {
this.dMenu = api;
}

@action
onLayoutSelect(layoutId) {
const found = LAYOUT_BUTTONS.find((b) => b.id === layoutId);
if (found) {
this.currentLayout = found;
this.keyValueStore.set({
key: "ai-persona-list-layout",
value: layoutId,
});
}
this.dMenu.close();
}

<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-personas"
@label={{i18n "discourse_ai.ai_persona.short_title"}}
/>
<section class="ai-persona-list-editor__current admin-detail pull-left">
<section class="ai-persona-list-editor__current admin-detail">
{{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}}
Expand All @@ -57,40 +181,115 @@ export default class AiPersonaListEditor extends Component {
</DPageSubheader>

{{#if @personas}}
<table class="content-list ai-persona-list-editor d-admin-table">
<div class="ai-persona-list-editor__controls">
<FilterInput
placeholder={{i18n "discourse_ai.ai_persona.filters.text"}}
@filterAction={{this.onNameFilterChange}}
@value={{this.filterValue}}
class="admin-filter__input"
@icons={{hash left="magnifying-glass"}}
/>
<DSelect
@value={{this.featureFilter}}
@includeNone={{false}}
@onChange={{this.onFeatureFilterChange}}
as |select|
>
{{#each this.featureFilterOptions as |option|}}
<select.Option @value={{option.value}}>
{{option.label}}
</select.Option>
{{/each}}
</DSelect>
{{#if this.capabilities.viewport.md}}
<DMenu
@modalForMobile={{true}}
@autofocus={{true}}
@identifier="persona-list-layout"
@onRegisterApi={{this.onRegisterApi}}
@triggerClass="btn-default btn-icon"
>
<:trigger>
{{icon this.currentLayout.icon}}
</:trigger>
<:content>
<DropdownMenu as |dropdown|>
{{#each LAYOUT_BUTTONS as |button|}}
<dropdown.item>
<DButton
@label={{button.label}}
@icon={{button.icon}}
class="btn-transparent"
@action={{fn this.onLayoutSelect button.id}}
/>
</dropdown.item>
{{/each}}
</DropdownMenu>
</:content>
</DMenu>
{{/if}}
</div>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="discourse_ai.ai_persona.new"
@ctaRoute="adminPlugins.show.discourse-ai-personas.new"
@ctaClass="ai-persona-list-editor__empty-new-button"
@emptyLabel="discourse_ai.ai_persona.no_personas"
/>
{{/if}}

{{#if this.filteredPersonas}}
<table
class={{concatClass
"content-list ai-persona-list-editor d-admin-table"
(concat "--layout-" this.currentLayout.id)
}}
>
<thead>
<tr>
<th>{{i18n "discourse_ai.ai_persona.name"}}</th>
<th>{{i18n "discourse_ai.ai_persona.list.enabled"}}</th>
<th></th>
<th>{{i18n "discourse_ai.features.short_title"}}</th>
</tr>
</thead>
<tbody>
{{#each @personas as |persona|}}
{{#each this.filteredPersonas as |persona|}}
<tr
data-persona-id={{persona.id}}
class={{concatClass
"ai-persona-list__row d-admin-row__content"
(if persona.priority "priority")
(if persona.priority "--priority")
(if persona.enabled "--enabled")
}}
>
<td class="d-admin-row__overview">
<div class="ai-persona-list__name-with-description">
<div class="ai-persona-list__name">
<strong>
{{persona.name}}
</strong>
</div>
<h3 class="ai-persona-list__name">
{{#if persona.user}}
{{avatar persona.user imageSize="tiny"}}
{{/if}}
{{persona.name}}
</h3>
<div class="ai-persona-list__description">
{{persona.description}}
</div>
</div>
</td>
<td class="d-admin-row__detail">
<DToggleSwitch
@state={{persona.enabled}}
{{on "click" (fn this.toggleEnabled persona)}}
/>
<td class="d-admin-row__features">
{{#each persona.features as |feature|}}
<DButton
class="btn-flat btn-small ai-persona-list__row-item-feature"
@translatedLabel={{i18n
(concat
"discourse_ai.features."
feature.module_name
"."
feature.name
)
}}
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
{{/each}}
</td>
<td class="d-admin-row__controls">
<LinkTo
Expand All @@ -104,12 +303,17 @@ export default class AiPersonaListEditor extends Component {
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="discourse_ai.ai_persona.new"
@ctaRoute="adminPlugins.show.discourse-ai-personas.new"
@ctaClass="ai-persona-list-editor__empty-new-button"
@emptyLabel="discourse_ai.ai_persona.no_personas"
/>
<div class="ai-persona-list-editor__no-results">

<h3>{{i18n "discourse_ai.ai_persona.filters.no_results"}}</h3>

<DButton
@icon="arrow-rotate-left"
@label="discourse_ai.ai_persona.filters.reset"
@action={{this.resetAndFocus}}
class="btn-default"
/>
</div>
{{/if}}
{{/if}}
</section>
Expand Down
Loading
Loading