UX: add features to persona list and other style updates (#1405)

This commit is contained in:
Kris 2025-06-12 08:23:10 -04:00 committed by GitHub
parent 02bc9f645e
commit 22da440130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 467 additions and 32 deletions

View File

@ -9,15 +9,16 @@ module DiscourseAi
def index def index
ai_personas = ai_personas =
AiPersona.ordered.map do |persona| AiPersona
# we use a special serializer here cause names and descriptions are .ordered
# localized for system personas .includes(:user, :uploads)
LocalizedAiPersonaSerializer.new(persona, root: false) .map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) }
end
tools = tools =
DiscourseAi::Personas::Persona.all_available_tools.map do |tool| DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false) AiToolSerializer.new(tool, root: false)
end end
AiTool AiTool
.where(enabled: true) .where(enabled: true)
.each do |tool| .each do |tool|
@ -31,10 +32,12 @@ module DiscourseAi
), ),
} }
end end
llms = llms =
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization( DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map, allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
) )
render json: { render json: {
ai_personas: ai_personas, ai_personas: ai_personas,
meta: { meta: {

View File

@ -32,7 +32,8 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
:allow_personal_messages, :allow_personal_messages,
:force_default_llm, :force_default_llm,
:response_format, :response_format,
:examples :examples,
:features
has_one :user, serializer: BasicUserSerializer, embed: :object has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :rag_uploads, serializer: UploadSerializer, embed: :object has_many :rag_uploads, serializer: UploadSerializer, embed: :object
@ -53,4 +54,10 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
def default_llm def default_llm
LlmModel.find_by(id: object.default_llm_id) LlmModel.find_by(id: object.default_llm_id)
end end
def features
object.features.map do |feature|
{ id: feature.module_id, module_name: feature.module_name, name: feature.name }
end
end
end end

View File

@ -1,20 +1,114 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { fn } from "@ember/helper"; import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier"; import { concat, fn, hash } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { LinkTo } from "@ember/routing"; import { LinkTo } from "@ember/routing";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { gt } from "truth-helpers";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item"; import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader"; 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 concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list"; import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import DMenu from "float-kit/components/d-menu";
import AiPersonaEditor from "./ai-persona-editor"; 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 { export default class AiPersonaListEditor extends Component {
@service adminPluginNavManager; @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.module_name?.toLowerCase().includes(term)
);
const llmMatches = persona.default_llm?.display_name
?.toLowerCase()
.includes(term);
return textMatches || featureMatches || llmMatches;
});
}
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((name) => ({
value: name,
label: i18n(`discourse_ai.features.${name}.name`),
})),
];
}
@action @action
async toggleEnabled(persona) { async toggleEnabled(persona) {
@ -30,12 +124,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> <template>
<DBreadcrumbsItem <DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-personas" @path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-personas"
@label={{i18n "discourse_ai.ai_persona.short_title"}} @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}} {{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} /> <AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}} {{else}}
@ -57,40 +186,139 @@ export default class AiPersonaListEditor extends Component {
</DPageSubheader> </DPageSubheader>
{{#if @personas}} {{#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> <thead>
<tr> <tr>
<th>{{i18n "discourse_ai.ai_persona.name"}}</th> <th>{{i18n "discourse_ai.ai_persona.name"}}</th>
<th>{{i18n "discourse_ai.ai_persona.list.enabled"}}</th> <th>{{i18n "discourse_ai.llms.short_title"}}</th>
<th></th> <th>{{i18n "discourse_ai.features.short_title"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{#each @personas as |persona|}} {{#each this.filteredPersonas as |persona|}}
<tr <tr
data-persona-id={{persona.id}} data-persona-id={{persona.id}}
class={{concatClass class={{concatClass
"ai-persona-list__row d-admin-row__content" "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"> <td class="d-admin-row__overview">
<div class="ai-persona-list__name-with-description"> <div class="ai-persona-list__name-with-description">
<div class="ai-persona-list__name"> <h3 class="ai-persona-list__name">
<strong> {{#if persona.user}}
{{persona.name}} {{avatar persona.user imageSize="tiny"}}
</strong> {{/if}}
</div> {{persona.name}}
</h3>
<div class="ai-persona-list__description"> <div class="ai-persona-list__description">
{{persona.description}} {{persona.description}}
</div> </div>
</div> </div>
</td> </td>
<td class="d-admin-row__detail"> <td class="d-admin-row__llms">
<DToggleSwitch {{#if persona.default_llm}}
@state={{persona.enabled}} <span class="--card-label">
{{on "click" (fn this.toggleEnabled persona)}} {{i18n "discourse_ai.ai_persona.llms_list"}}
/> </span>
<DButton
class="btn-flat btn-small ai-persona-list__row-item-feature"
@translatedLabel={{persona.default_llm.display_name}}
@route="adminPlugins.show.discourse-ai-llms.edit"
@routeModels={{persona.default_llm.id}}
/>
{{/if}}
</td>
<td class="d-admin-row__features">
{{#if persona.features.length}}
<span class="--card-label">
{{i18n
"discourse_ai.ai_persona.features_list"
count=persona.features.length
}}
</span>
{{#each persona.features as |feature index|}}
<span class="d-admin-row__row-feature-list">
{{#if (gt index 0)}}, {{/if}}
<DButton
class="btn-flat btn-small ai-persona-list__row-item-feature"
@translatedLabel={{i18n
(concat
"discourse_ai.features."
feature.module_name
".name"
)
}}
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</span>
{{/each}}
{{/if}}
</td> </td>
<td class="d-admin-row__controls"> <td class="d-admin-row__controls">
<LinkTo <LinkTo
@ -104,12 +332,17 @@ export default class AiPersonaListEditor extends Component {
</tbody> </tbody>
</table> </table>
{{else}} {{else}}
<AdminConfigAreaEmptyList <div class="ai-persona-list-editor__no-results">
@ctaLabel="discourse_ai.ai_persona.new"
@ctaRoute="adminPlugins.show.discourse-ai-personas.new" <h3>{{i18n "discourse_ai.ai_persona.filters.no_results"}}</h3>
@ctaClass="ai-persona-list-editor__empty-new-button"
@emptyLabel="discourse_ai.ai_persona.no_personas" <DButton
/> @icon="arrow-rotate-left"
@label="discourse_ai.ai_persona.filters.reset"
@action={{this.resetAndFocus}}
class="btn-default"
/>
</div>
{{/if}} {{/if}}
{{/if}} {{/if}}
</section> </section>

View File

@ -1,8 +1,28 @@
@use "lib/viewport";
.admin-contents .ai-persona-list-editor { .admin-contents .ai-persona-list-editor {
margin-top: 0; margin-top: 0;
} }
.ai-persona-list-editor { .ai-persona-list-editor {
@include viewport.until(md) {
td {
border: none;
padding: 0;
&.d-admin-row__llms,
&.d-admin-row__features {
padding-block: 0;
.--card-label {
display: inline-block;
font-size: var(--font-down-1);
color: var(--primary-high);
}
}
}
}
&__header { &__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -21,6 +41,163 @@
li.disabled { li.disabled {
opacity: 0.5; opacity: 0.5;
} }
&__controls {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-4);
.filter-input-container {
flex: 4 1 auto;
}
.d-select {
flex: 1 1 auto;
width: auto;
height: auto;
}
}
&__no-results {
display: flex;
flex-direction: column;
text-align: center;
justify-content: center;
padding: var(--space-6);
gap: var(--space-2);
h3 {
font-weight: normal;
}
.btn {
align-self: center;
}
}
&.--layout-table {
.--card-label {
display: none;
}
}
&.--layout-card {
tbody {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18em, 1fr));
gap: var(--space-4);
border: none;
}
thead {
display: none;
}
.d-admin-row__content {
display: grid;
grid-template-rows: auto 1fr auto auto;
grid-template-columns: 1fr auto;
border: 1px solid var(--primary-low);
padding: var(--space-2) var(--space-4) var(--space-4);
border-radius: var(--d-border-radius);
.d-admin-row__overview,
.ai-persona-list__name-with-description {
display: contents;
}
.ai-persona-list__name {
grid-row: 1;
grid-column: 1;
font-size: var(--font-up-1);
display: inline;
align-self: center;
.avatar {
height: 1.25em;
position: relative;
bottom: 0.15em;
}
}
.ai-persona-list__description {
grid-row: 2;
grid-column: 1;
margin: var(--space-2) 0;
}
.d-admin-row__controls {
grid-row: 1;
grid-column: 2;
}
.d-admin-row__features {
grid-row: 4;
grid-column: 1 / span 2;
padding: 0;
.btn {
margin-top: var(--space-0);
}
}
.d-admin-row__llms {
grid-row: 3;
grid-column: 1 / span 2;
padding: 0;
.btn {
margin-top: var(--space-2);
}
}
.--card-label {
color: var(--primary-high);
font-size: var(--font-down-1);
}
}
}
.ai-persona-list {
&__row-item-feature {
padding: 0;
text-align: left;
}
&__description {
color: var(--primary-high);
}
&__name {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--font-up-0);
color: var(--primary);
margin: 0;
padding: 0;
line-height: var(--line-height-medium);
.avatar {
width: auto;
height: 1.25em;
}
}
&__row {
&:hover {
background: transparent;
}
&__overview {
padding-left: var(--space-2);
}
}
}
.d-admin-row__row-feature-list {
color: var(--primary-medium);
}
} }
.ai-persona-tool-option-editor { .ai-persona-tool-option-editor {

View File

@ -211,10 +211,13 @@ en:
custom_prompt: "Custom prompt" custom_prompt: "Custom prompt"
image_caption: "Caption images" image_caption: "Caption images"
modals: modals:
select_option: "Select an option..." select_option: "Select an option..."
layout:
table: "Table"
card: "Card"
spam: spam:
short_title: "Spam" short_title: "Spam"
title: "Configure spam handling" title: "Configure spam handling"
@ -379,6 +382,18 @@ en:
title: "AI bot options" title: "AI bot options"
save_first: "More AI bot options will become available once you save the persona." save_first: "More AI bot options will become available once you save the persona."
filters:
text: "Find a persona"
reset: "Reset"
no_results: "No personas found matching your filters."
all_features: "Any feature"
features_list:
one: "Feature:"
other: "Features:"
llms_list: "LLM:"
rag: rag:
title: "RAG" title: "RAG"
options: options: