diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index 2b57dda9..a63e7b1d 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -9,15 +9,16 @@ module DiscourseAi 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| @@ -31,10 +32,12 @@ module DiscourseAi ), } 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: { diff --git a/app/serializers/localized_ai_persona_serializer.rb b/app/serializers/localized_ai_persona_serializer.rb index 52ff044b..90e14c0d 100644 --- a/app/serializers/localized_ai_persona_serializer.rb +++ b/app/serializers/localized_ai_persona_serializer.rb @@ -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 @@ -53,4 +54,10 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer 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 diff --git a/assets/javascripts/discourse/components/ai-persona-list-editor.gjs b/assets/javascripts/discourse/components/ai-persona-list-editor.gjs index 69cadec1..ec3de4ad 100644 --- a/assets/javascripts/discourse/components/ai-persona-list-editor.gjs +++ b/assets/javascripts/discourse/components/ai-persona-list-editor.gjs @@ -1,20 +1,114 @@ 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 { 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 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.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 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(); + } +