FEATURE: Configure persona backed features in admin panel (#1245)
In this feature update, we add the UI for the ability to easily configure persona backed AI-features. The feature will still be hidden until structured responses are complete.
This commit is contained in:
parent
129ced9088
commit
4de39a07e5
|
@ -0,0 +1,27 @@
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
import SiteSetting from "admin/models/site-setting";
|
||||||
|
|
||||||
|
export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRoute {
|
||||||
|
async model(params) {
|
||||||
|
const allFeatures = this.modelFor(
|
||||||
|
"adminPlugins.show.discourse-ai-features"
|
||||||
|
);
|
||||||
|
const id = parseInt(params.id, 10);
|
||||||
|
const currentFeature = allFeatures.find((feature) => feature.id === id);
|
||||||
|
|
||||||
|
const { site_settings } = await ajax("/admin/config/site_settings.json", {
|
||||||
|
data: {
|
||||||
|
filter_area: `ai-features/${currentFeature.ref}`,
|
||||||
|
plugin: "discourse-ai",
|
||||||
|
category: "discourse_ai",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentFeature.feature_settings = site_settings.map((setting) =>
|
||||||
|
SiteSetting.create(setting)
|
||||||
|
);
|
||||||
|
|
||||||
|
return currentFeature;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
|
export default class AdminPluginsShowDiscourseAiFeatures extends DiscourseRoute {
|
||||||
|
@service store;
|
||||||
|
|
||||||
|
async model() {
|
||||||
|
return this.store.findAll("ai-feature");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import RouteTemplate from "ember-route-template";
|
||||||
|
import BackButton from "discourse/components/back-button";
|
||||||
|
import SiteSettingComponent from "admin/components/site-setting";
|
||||||
|
|
||||||
|
export default RouteTemplate(
|
||||||
|
<template>
|
||||||
|
<BackButton
|
||||||
|
@route="adminPlugins.show.discourse-ai-features"
|
||||||
|
@label="discourse_ai.features.back"
|
||||||
|
/>
|
||||||
|
<section class="ai-feature-editor__header">
|
||||||
|
<h2>{{@model.name}}</h2>
|
||||||
|
<p>{{@model.description}}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ai-feature-editor">
|
||||||
|
{{#each @model.feature_settings as |setting|}}
|
||||||
|
<div>
|
||||||
|
<SiteSettingComponent @setting={{setting}} />
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
);
|
|
@ -0,0 +1,156 @@
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
);
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module Admin
|
||||||
|
class AiFeaturesController < ::Admin::AdminController
|
||||||
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: serialize_features(DiscourseAi::Features.features)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
|
||||||
|
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serialize_features(features)
|
||||||
|
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_feature(feature)
|
||||||
|
return nil if feature.blank?
|
||||||
|
|
||||||
|
feature.merge(persona: serialize_persona(feature[:persona]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_persona(persona)
|
||||||
|
return nil if persona.blank?
|
||||||
|
|
||||||
|
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,6 +7,8 @@ module Jobs
|
||||||
def execute(args)
|
def execute(args)
|
||||||
interaction = args[:interaction]
|
interaction = args[:interaction]
|
||||||
|
|
||||||
|
return unless SiteSetting.ai_discord_search_enabled
|
||||||
|
|
||||||
if SiteSetting.ai_discord_search_mode == "persona"
|
if SiteSetting.ai_discord_search_mode == "persona"
|
||||||
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
|
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
|
||||||
else
|
else
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AiFeaturesPersonaSerializer < ApplicationSerializer
|
||||||
|
attributes :id, :name, :system_prompt, :allowed_groups, :enabled
|
||||||
|
|
||||||
|
def allowed_groups
|
||||||
|
Group
|
||||||
|
.where(id: object.allowed_group_ids)
|
||||||
|
.pluck(:id, :name)
|
||||||
|
.map { |id, name| { id: id, name: name } }
|
||||||
|
end
|
||||||
|
end
|
|
@ -29,5 +29,9 @@ export default {
|
||||||
this.route("edit", { path: "/:id/edit" });
|
this.route("edit", { path: "/:id/edit" });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.route("discourse-ai-features", { path: "ai-features" }, function () {
|
||||||
|
this.route("edit", { path: "/:id/edit" });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import RestAdapter from "discourse/adapters/rest";
|
||||||
|
|
||||||
|
export default class AiFeatureAdapter extends RestAdapter {
|
||||||
|
jsonMode = true;
|
||||||
|
|
||||||
|
basePath() {
|
||||||
|
return "/admin/plugins/discourse-ai/";
|
||||||
|
}
|
||||||
|
|
||||||
|
pathFor(store, type, findArgs) {
|
||||||
|
// removes underscores which are implemented in base
|
||||||
|
let path =
|
||||||
|
this.basePath(store, type, findArgs) +
|
||||||
|
store.pluralize(this.apiNameFor(type));
|
||||||
|
return this.appendQueryParams(path, findArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiNameFor() {
|
||||||
|
return "ai-feature";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import RestModel from "discourse/models/rest";
|
||||||
|
|
||||||
|
export default class AiFeature extends RestModel {
|
||||||
|
createProperties() {
|
||||||
|
return this.getProperties(
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"ref",
|
||||||
|
"description",
|
||||||
|
"enable_setting",
|
||||||
|
"persona",
|
||||||
|
"persona_setting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,6 +41,12 @@ 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",
|
||||||
|
// route: "adminPlugins.show.discourse-ai-features",
|
||||||
|
// description: "discourse_ai.features.description",
|
||||||
|
// },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
.ai-feature-list {
|
||||||
|
&__configured-features {
|
||||||
|
margin-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-item-name,
|
||||||
|
&__row-item-description {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-item-persona {
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
@include ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-item-groups {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.5em 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: var(--font-down-2);
|
||||||
|
border-radius: var(--d-border-radius);
|
||||||
|
background: var(--primary-very-low);
|
||||||
|
border: 1px solid var(--primary-low);
|
||||||
|
padding: 1px 3px;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-feature-editor {
|
||||||
|
&__header {
|
||||||
|
border-bottom: 1px solid var(--primary-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: var(--font-down-1-rem);
|
||||||
|
color: var(--primary-high);
|
||||||
|
|
||||||
|
a[title="View change history"],
|
||||||
|
.history-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-value {
|
||||||
|
.desc {
|
||||||
|
font-size: var(--font-down-1-rem);
|
||||||
|
color: var(--primary-high-or-secondary-low);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-controls,
|
||||||
|
.setting-controls__undo {
|
||||||
|
font-size: var(--font-down-1-rem);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -165,6 +165,20 @@ en:
|
||||||
discourse_ai:
|
discourse_ai:
|
||||||
title: "AI"
|
title: "AI"
|
||||||
|
|
||||||
|
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."
|
||||||
|
back: "Back"
|
||||||
|
list:
|
||||||
|
header:
|
||||||
|
name: "Name"
|
||||||
|
persona: "Persona"
|
||||||
|
groups: "Groups"
|
||||||
|
edit: "Edit"
|
||||||
|
set_up: "Set up"
|
||||||
|
configured_features: "Configured features"
|
||||||
|
unconfigured_features: "Unconfigured features"
|
||||||
|
|
||||||
modals:
|
modals:
|
||||||
select_option: "Select an option..."
|
select_option: "Select an option..."
|
||||||
|
|
||||||
|
|
|
@ -81,11 +81,12 @@ en:
|
||||||
ai_embeddings_semantic_search_hyde_model: "Model used to expand keywords to get better results during a semantic search"
|
ai_embeddings_semantic_search_hyde_model: "Model used to expand keywords to get better results during a semantic search"
|
||||||
ai_embeddings_per_post_enabled: Generate embeddings for each post
|
ai_embeddings_per_post_enabled: Generate embeddings for each post
|
||||||
|
|
||||||
ai_summarization_enabled: "Enable the topic summarization module."
|
ai_summarization_enabled: "Enable the summarize feature"
|
||||||
ai_summarization_model: "Model to use for summarization."
|
ai_summarization_model: "Model to use for summarization"
|
||||||
|
ai_summarization_persona: "Persona to use for summarize feature"
|
||||||
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."
|
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."
|
||||||
ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs."
|
ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs."
|
||||||
ai_summary_gists_enabled: "Generate brief summaries of latest replies in topics automatically."
|
ai_summary_gists_enabled: "Generate brief summaries of latest replies in topics automatically"
|
||||||
ai_summary_gists_allowed_groups: "Groups allowed to see gists in the hot topics list."
|
ai_summary_gists_allowed_groups: "Groups allowed to see gists in the hot topics list."
|
||||||
ai_summary_backfill_maximum_topics_per_hour: "Number of topic summaries to backfill per hour."
|
ai_summary_backfill_maximum_topics_per_hour: "Number of topic summaries to backfill per hour."
|
||||||
|
|
||||||
|
@ -104,6 +105,13 @@ en:
|
||||||
ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search"
|
ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search"
|
||||||
ai_google_custom_search_cx: "CX for Google Custom Search API"
|
ai_google_custom_search_cx: "CX for Google Custom Search API"
|
||||||
|
|
||||||
|
ai_discord_search_enabled: "Enables the Discord search feature"
|
||||||
|
ai_discord_app_id: "The ID of the Discord application you would like to connect Discord search to"
|
||||||
|
ai_discord_app_public_key: "The public key of the Discord application you would like to connect Discord search to"
|
||||||
|
ai_discord_search_mode: "Select the search mode to use for Discord search"
|
||||||
|
ai_discord_search_persona: "The persona to use for Discord search."
|
||||||
|
ai_discord_allowed_guilds: "Discord guilds (servers) where the bot is allowed to search"
|
||||||
|
|
||||||
reviewables:
|
reviewables:
|
||||||
reasons:
|
reasons:
|
||||||
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
|
flagged_by_toxicity: The AI plugin flagged this after classifying it as toxic.
|
||||||
|
@ -487,6 +495,20 @@ 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"
|
||||||
|
|
||||||
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"
|
||||||
|
|
|
@ -110,6 +110,11 @@ Discourse::Application.routes.draw do
|
||||||
controller: "discourse_ai/admin/ai_embeddings" do
|
controller: "discourse_ai/admin/ai_embeddings" do
|
||||||
collection { get :test }
|
collection { get :test }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :ai_features,
|
||||||
|
only: %i[index edit],
|
||||||
|
path: "ai-features",
|
||||||
|
controller: "discourse_ai/admin/ai_features"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -234,6 +234,7 @@ discourse_ai:
|
||||||
default: false
|
default: false
|
||||||
client: true
|
client: true
|
||||||
validator: "DiscourseAi::Configuration::LlmDependencyValidator"
|
validator: "DiscourseAi::Configuration::LlmDependencyValidator"
|
||||||
|
area: "ai-features/summarization"
|
||||||
ai_summarization_model:
|
ai_summarization_model:
|
||||||
default: ""
|
default: ""
|
||||||
allow_any: false
|
allow_any: false
|
||||||
|
@ -245,11 +246,12 @@ discourse_ai:
|
||||||
default: "-11"
|
default: "-11"
|
||||||
type: enum
|
type: enum
|
||||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
||||||
|
area: "ai-features/summarization"
|
||||||
ai_pm_summarization_allowed_groups:
|
ai_pm_summarization_allowed_groups:
|
||||||
type: group_list
|
type: group_list
|
||||||
list_type: compact
|
list_type: compact
|
||||||
default: ""
|
default: ""
|
||||||
|
area: "ai-features/summarization"
|
||||||
ai_custom_summarization_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
|
ai_custom_summarization_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01
|
||||||
type: group_list
|
type: group_list
|
||||||
list_type: compact
|
list_type: compact
|
||||||
|
@ -257,12 +259,12 @@ discourse_ai:
|
||||||
hidden: true
|
hidden: true
|
||||||
ai_summary_gists_enabled:
|
ai_summary_gists_enabled:
|
||||||
default: false
|
default: false
|
||||||
hidden: true
|
area: "ai-features/gists"
|
||||||
ai_summary_gists_persona:
|
ai_summary_gists_persona:
|
||||||
default: "-12"
|
default: "-12"
|
||||||
type: enum
|
type: enum
|
||||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
||||||
hidden: true
|
area: "ai-features/gists"
|
||||||
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
|
||||||
|
@ -277,17 +279,20 @@ discourse_ai:
|
||||||
default: 30
|
default: 30
|
||||||
min: 1
|
min: 1
|
||||||
max: 10000
|
max: 10000
|
||||||
|
area: "ai-features/summarization"
|
||||||
ai_summary_backfill_maximum_topics_per_hour:
|
ai_summary_backfill_maximum_topics_per_hour:
|
||||||
default: 0
|
default: 0
|
||||||
min: 0
|
min: 0
|
||||||
max: 10000
|
max: 10000
|
||||||
|
area: "ai-features/summarization"
|
||||||
ai_summary_backfill_minimum_word_count:
|
ai_summary_backfill_minimum_word_count:
|
||||||
default: 200
|
default: 200
|
||||||
hidden: true
|
area: "ai-features/summarization"
|
||||||
|
|
||||||
ai_bot_enabled:
|
ai_bot_enabled:
|
||||||
default: false
|
default: false
|
||||||
client: true
|
client: true
|
||||||
|
area: "ai-features/discoveries"
|
||||||
ai_bot_enable_chat_warning:
|
ai_bot_enable_chat_warning:
|
||||||
default: false
|
default: false
|
||||||
client: true
|
client: true
|
||||||
|
@ -326,9 +331,9 @@ discourse_ai:
|
||||||
ai_bot_discover_persona:
|
ai_bot_discover_persona:
|
||||||
default: ""
|
default: ""
|
||||||
type: enum
|
type: enum
|
||||||
hidden: true
|
|
||||||
client: true
|
client: true
|
||||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
||||||
|
area: "ai-features/discoveries"
|
||||||
ai_automation_max_triage_per_minute:
|
ai_automation_max_triage_per_minute:
|
||||||
default: 60
|
default: 60
|
||||||
hidden: true
|
hidden: true
|
||||||
|
@ -341,26 +346,35 @@ discourse_ai:
|
||||||
type: list
|
type: list
|
||||||
list_type: compact
|
list_type: compact
|
||||||
|
|
||||||
|
ai_discord_search_enabled:
|
||||||
|
default: false
|
||||||
|
client: true
|
||||||
|
area: "ai-features/discord_search"
|
||||||
ai_discord_app_id:
|
ai_discord_app_id:
|
||||||
default: ""
|
default: ""
|
||||||
client: false
|
client: false
|
||||||
|
area: "ai-features/discord_search"
|
||||||
ai_discord_app_public_key:
|
ai_discord_app_public_key:
|
||||||
default: ""
|
default: ""
|
||||||
client: false
|
client: false
|
||||||
|
area: "ai-features/discord_search"
|
||||||
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"
|
||||||
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"
|
||||||
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"
|
||||||
|
|
||||||
ai_spam_detection_enabled:
|
ai_spam_detection_enabled:
|
||||||
default: false
|
default: false
|
||||||
|
|
|
@ -105,10 +105,7 @@ module DiscourseAi
|
||||||
plugin.add_to_serializer(
|
plugin.add_to_serializer(
|
||||||
:current_user,
|
:current_user,
|
||||||
:ai_enabled_personas,
|
:ai_enabled_personas,
|
||||||
include_condition: -> do
|
include_condition: -> { scope.authenticated? },
|
||||||
SiteSetting.ai_bot_enabled && scope.authenticated? &&
|
|
||||||
scope.user.in_any_groups?(SiteSetting.ai_bot_allowed_groups_map)
|
|
||||||
end,
|
|
||||||
) do
|
) do
|
||||||
DiscourseAi::Personas::Persona
|
DiscourseAi::Personas::Persona
|
||||||
.all(user: scope.user)
|
.all(user: scope.user)
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module Features
|
||||||
|
def self.feature_config
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name_ref: "summarization",
|
||||||
|
name_key: "discourse_ai.features.summarization.name",
|
||||||
|
description_key: "discourse_ai.features.summarization.description",
|
||||||
|
persona_setting_name: "ai_summarization_persona",
|
||||||
|
enable_setting_name: "ai_summarization_enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name_ref: "gists",
|
||||||
|
name_key: "discourse_ai.features.gists.name",
|
||||||
|
description_key: "discourse_ai.features.gists.description",
|
||||||
|
persona_setting_name: "ai_summary_gists_persona",
|
||||||
|
enable_setting_name: "ai_summary_gists_enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name_ref: "discoveries",
|
||||||
|
name_key: "discourse_ai.features.discoveries.name",
|
||||||
|
description_key: "discourse_ai.features.discoveries.description",
|
||||||
|
persona_setting_name: "ai_bot_discover_persona",
|
||||||
|
enable_setting_name: "ai_bot_enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name_ref: "discord_search",
|
||||||
|
name_key: "discourse_ai.features.discord_search.name",
|
||||||
|
description_key: "discourse_ai.features.discord_search.description",
|
||||||
|
persona_setting_name: "ai_discord_search_persona",
|
||||||
|
enable_setting_name: "ai_discord_search_enabled",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.features
|
||||||
|
feature_config.map do |feature|
|
||||||
|
{
|
||||||
|
id: feature[:id],
|
||||||
|
ref: feature[:name_ref],
|
||||||
|
name: I18n.t(feature[:name_key]),
|
||||||
|
description: I18n.t(feature[:description_key]),
|
||||||
|
persona: AiPersona.find_by(id: SiteSetting.get(feature[:persona_setting_name])),
|
||||||
|
persona_setting: {
|
||||||
|
name: feature[:persona_setting_name],
|
||||||
|
value: SiteSetting.get(feature[:persona_setting_name]),
|
||||||
|
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
|
||||||
|
|
||||||
|
def self.find_feature_by_id(id)
|
||||||
|
lookup = features.index_by { |f| f[:id] }
|
||||||
|
lookup[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_feature_by_ref(name_ref)
|
||||||
|
lookup = features.index_by { |f| f[:ref] }
|
||||||
|
lookup[name_ref]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_feature_id_by_ref(name_ref)
|
||||||
|
find_feature_by_ref(name_ref)&.dig(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.feature_area(name_ref)
|
||||||
|
name_ref = name_ref.to_s if name_ref.is_a?(Symbol)
|
||||||
|
find_feature_by_ref(name_ref) || raise(ArgumentError, "Feature not found: #{name_ref}")
|
||||||
|
"ai-features/#{name_ref}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -27,6 +27,7 @@ enabled_site_setting :discourse_ai_enabled
|
||||||
register_asset "stylesheets/common/streaming.scss"
|
register_asset "stylesheets/common/streaming.scss"
|
||||||
register_asset "stylesheets/common/ai-blinking-animation.scss"
|
register_asset "stylesheets/common/ai-blinking-animation.scss"
|
||||||
register_asset "stylesheets/common/ai-user-settings.scss"
|
register_asset "stylesheets/common/ai-user-settings.scss"
|
||||||
|
register_asset "stylesheets/common/ai-features.scss"
|
||||||
|
|
||||||
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||||
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
|
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
|
||||||
|
@ -69,6 +70,11 @@ end
|
||||||
Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::DiscourseAi)
|
Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::DiscourseAi)
|
||||||
|
|
||||||
require_relative "lib/engine"
|
require_relative "lib/engine"
|
||||||
|
require_relative "lib/features"
|
||||||
|
|
||||||
|
DiscourseAi::Features.feature_config.each do |feature|
|
||||||
|
register_site_setting_area("ai-features/#{feature[:name_ref]}")
|
||||||
|
end
|
||||||
|
|
||||||
after_initialize do
|
after_initialize do
|
||||||
if defined?(Rack::MiniProfiler)
|
if defined?(Rack::MiniProfiler)
|
||||||
|
|
|
@ -17,6 +17,7 @@ RSpec.describe Jobs::StreamDiscordReply, type: :job do
|
||||||
fab!(:persona) { Fabricate(:ai_persona, default_llm_id: llm_model.id) }
|
fab!(:persona) { Fabricate(:ai_persona, default_llm_id: llm_model.id) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
SiteSetting.ai_discord_search_enabled = true
|
||||||
SiteSetting.ai_discord_search_mode = "persona"
|
SiteSetting.ai_discord_search_mode = "persona"
|
||||||
SiteSetting.ai_discord_search_persona = persona.id
|
SiteSetting.ai_discord_search_persona = persona.id
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe DiscourseAi::Admin::AiFeaturesController do
|
||||||
|
let(:controller) { described_class.new }
|
||||||
|
fab!(:admin)
|
||||||
|
fab!(:group)
|
||||||
|
fab!(:llm_model)
|
||||||
|
fab!(:summarizer_persona) { Fabricate(:ai_persona) }
|
||||||
|
fab!(:alternate_summarizer_persona) { Fabricate(:ai_persona) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(admin)
|
||||||
|
SiteSetting.ai_bot_enabled = true
|
||||||
|
SiteSetting.discourse_ai_enabled = true
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#index" do
|
||||||
|
it "lists all features backed by personas" do
|
||||||
|
get "/admin/plugins/discourse-ai/ai-features.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["ai_features"].count).to eq(4)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#edit" do
|
||||||
|
it "returns a success response" do
|
||||||
|
get "/admin/plugins/discourse-ai/ai-features/1/edit.json"
|
||||||
|
expect(response.parsed_body["name"]).to eq(I18n.t "discourse_ai.features.summarization.name")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,33 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe AiFeaturesPersonaSerializer do
|
||||||
|
fab!(:admin)
|
||||||
|
fab!(:ai_persona)
|
||||||
|
fab!(:group)
|
||||||
|
fab!(:group_2) { Fabricate(:group) }
|
||||||
|
|
||||||
|
describe "serialized attributes" do
|
||||||
|
before do
|
||||||
|
ai_persona.allowed_group_ids = [group.id, group_2.id]
|
||||||
|
ai_persona.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when there is a persona with allowed groups" do
|
||||||
|
let(:allowed_groups) do
|
||||||
|
Group
|
||||||
|
.where(id: ai_persona.allowed_group_ids)
|
||||||
|
.pluck(:id, :name)
|
||||||
|
.map { |id, name| { id: id, name: name } }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "display every participant" do
|
||||||
|
serialized = described_class.new(ai_persona, scope: Guardian.new(admin), root: nil)
|
||||||
|
expect(serialized.id).to eq(ai_persona.id)
|
||||||
|
expect(serialized.name).to eq(ai_persona.name)
|
||||||
|
expect(serialized.system_prompt).to eq(ai_persona.system_prompt)
|
||||||
|
expect(serialized.allowed_groups).to eq(allowed_groups)
|
||||||
|
expect(serialized.enabled).to eq(ai_persona.enabled)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,64 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe "Admin AI features configuration", type: :system, js: true do
|
||||||
|
fab!(:admin)
|
||||||
|
fab!(:llm_model)
|
||||||
|
fab!(:summarization_persona) { Fabricate(:ai_persona) }
|
||||||
|
fab!(:group_1) { Fabricate(:group) }
|
||||||
|
fab!(:group_2) { Fabricate(:group) }
|
||||||
|
let(:page_header) { PageObjects::Components::DPageHeader.new }
|
||||||
|
let(:form) { PageObjects::Components::FormKit.new("form") }
|
||||||
|
let(:ai_features_page) { PageObjects::Pages::AdminAiFeatures.new }
|
||||||
|
|
||||||
|
before do
|
||||||
|
summarization_persona.allowed_group_ids = [group_1.id, group_2.id]
|
||||||
|
summarization_persona.save!
|
||||||
|
assign_fake_provider_to(:ai_summarization_model)
|
||||||
|
SiteSetting.ai_summarization_enabled = true
|
||||||
|
SiteSetting.ai_summarization_persona = summarization_persona.id
|
||||||
|
sign_in(admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "lists all persona backed AI features separated by configured/unconfigured" do
|
||||||
|
ai_features_page.visit
|
||||||
|
expect(
|
||||||
|
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_unconfigured_feature_items(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "lists the persona used for the corresponding AI feature" do
|
||||||
|
ai_features_page.visit
|
||||||
|
expect(ai_features_page).to have_feature_persona(summarization_persona.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "lists the groups allowed to use the AI feature" do
|
||||||
|
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
|
||||||
|
visit "/admin/plugins/discourse-ai/ai-features"
|
||||||
|
expect(page).to have_css(".d-breadcrumbs")
|
||||||
|
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
|
||||||
|
|
||||||
|
it "shows edit page with settings" do
|
||||||
|
ai_features_page.visit
|
||||||
|
ai_features_page.click_edit_feature(I18n.t("discourse_ai.features.summarization.name"))
|
||||||
|
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")
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,51 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PageObjects
|
||||||
|
module Pages
|
||||||
|
class AdminAiFeatures < PageObjects::Pages::Base
|
||||||
|
CONFIGURED_FEATURES_TABLE = ".ai-feature-list__configured-features .d-admin-table"
|
||||||
|
UNCONFIGURED_FEATURES_TABLE = ".ai-feature-list__unconfigured-features .d-admin-table"
|
||||||
|
|
||||||
|
def visit
|
||||||
|
page.visit("/admin/plugins/discourse-ai/ai-features")
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def configured_features_table
|
||||||
|
page.find(CONFIGURED_FEATURES_TABLE)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unconfigured_features_table
|
||||||
|
page.find(UNCONFIGURED_FEATURES_TABLE)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_configured_feature_items?(count)
|
||||||
|
page.has_css?("#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__row", count: count)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_unconfigured_feature_items?(count)
|
||||||
|
page.has_css?("#{UNCONFIGURED_FEATURES_TABLE} .ai-feature-list__row", count: count)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_feature_persona?(name)
|
||||||
|
page.has_css?(
|
||||||
|
"#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__persona .d-button-label ",
|
||||||
|
text: name,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_feature_groups?(groups)
|
||||||
|
listed_groups = page.find("#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__groups")
|
||||||
|
list_items = listed_groups.all("li", visible: true).map(&:text)
|
||||||
|
|
||||||
|
list_items.sort == groups.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
def click_edit_feature(feature_name)
|
||||||
|
page.find(
|
||||||
|
"#{CONFIGURED_FEATURES_TABLE} .ai-feature-list__row[data-feature-name='#{feature_name}'] .edit",
|
||||||
|
).click
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue