Compare commits

...
Author SHA1 Message Date
David Taylor 593e8dc2a5 DEV: Update modifyClass calls
- Update `controller:topic` modification to native class syntax
- Remove search-result-entry modification in favour of plugin-outlet-based solution
2024-12-02 11:49:22 +00:00
Roman Rizzi 0abd4b1244 FIX: Sentiment classification results needs to be transformed before saving (#983) 2024-11-29 17:31:56 -03:00
David Taylor 120a20c5cd DEV: Convert to native class syntax (#981) 2024-11-29 16:41:09 +01:00
Sam 0cb2c413ba FEATURE: exclude muted categories from category suggester (#979)
The logic here is that users do not particularly care about
topics in the category so we can exclude them from tag
and category suggestions
2024-11-29 12:17:28 +11:00
Rafael dos Santos Silva 80adefa1c1 FIX: Move count logic to the end for tag suggestions (#978) 2024-11-28 16:27:38 -03:00
Sam bc0657f478 FEATURE: AI Usage page (#964)
- Added a new admin interface to track AI usage metrics, including tokens, features, and models.
- Introduced a new route `/admin/plugins/discourse-ai/ai-usage` and supporting API endpoint in `AiUsageController`.
- Implemented `AiUsageSerializer` for structuring AI usage data.
- Integrated CSS stylings for charts and tables under `stylesheets/modules/llms/common/usage.scss`.
- Enhanced backend with `AiApiAuditLog` model changes: added `cached_tokens` column  (implemented with OpenAI for now) with relevant DB migration and indexing.
- Created `Report` module for efficient aggregation and filtering of AI usage metrics.
- Updated AI Bot title generation logic to log correctly to user vs bot
- Extended test coverage for the new tracking features, ensuring data consistency and access controls.
2024-11-29 06:26:48 +11:00
Roman Rizzi c980c34d77 REFACTOR: Simplify sentiment classification (#977)
This change adds a simpler class for sentiment classification, replacing the soon-to-be removed `Classificator` hierarchy. Additionally, it adds a method for classifying concurrently, speeding up the backfill rake task.
2024-11-28 15:38:23 -03:00
Rafael dos Santos Silva 6456a4f44a FIX: Keep related topics data cached when a user scrolls up (#976)
See https://meta.discourse.org/t/related-topics-missing-after-rereading-a-topic-with-more-than-20-posts/306147/6
2024-11-28 14:25:15 -03:00
Rafael dos Santos Silva eacc4ed945 UX: Fix styling of AI Helper on quick edit (#975) 2024-11-28 13:06:48 -03:00
David Taylor 4a2369356f DEV: Update linting (#974) 2024-11-28 16:07:42 +01:00
Rafael dos Santos Silva 4980a4b2f7 FIX: Multiple concurrent summaries could result in pg index errors (#973) 2024-11-28 11:53:04 -03:00
Keegan George 54ebbbe769 UX: Ensure suggestions are left aligned (#971)
This PR ensures that suggestions are aligned to the left. By default, they are center aligned which looks odd when there are long lines of text.
2024-11-27 20:53:30 -08:00
Keegan George 8b33ba3d19 FIX: Suggestion menus not showing in mobile (#970)
The suggestion menu z-index was not enough for mobile so it isn't being shown when it's used in `modalForMobile` bottom menus.
2024-11-27 19:51:03 -08:00
Keegan George f1c7ee8624 DEV: Better control what prompts can appear in post/composer (#969)
This PR updates the logic for the location map so it permits only the desired prompts through to the composer/post menu. Anything else won't be shown by default.

This PR also adds relevant tests to prevent regression.
2024-11-27 16:14:21 -08:00
Keegan George 4da033c667 FIX: Double render error with thumbnail suggestions (#968)
This PR fixes a bug where a double render error appears in the logs when thumbnails are suggested
2024-11-27 15:12:27 -08:00
Keegan George dabef02919 DEV: Prevent detect_text_locale from appearing in menus (#967)
### 🔍 Overview
With the recent changes to allow DiscourseAi in the translator plugin, `detect_text_locale` was needed as a CompletionPrompt. However, it is leaking into composer/post helper menus. This PR ensures we don't not show it in those menus.
2024-11-28 09:27:08 +11:00
Sam fbcc8e493a FEATURE: Skip PM scanning in LLM triage by default (#966)
Usually people do not want to scan personal messages. Sometimes
they may. In that case they can enable triage on personal messages.
2024-11-28 09:25:29 +11:00
Keegan George 6b7d7c1179 REFACTOR: Helper suggestions (#914)
This PR adds some updates to the Helper suggestions to improve it's functionality and modernize some of the codebase.
2024-11-27 12:21:03 -08:00
Rafael dos Santos SilvaandRoman Rizzi 0d3e6b2726 FIX: Fix ordering of random post embeddings backfill (#965)
* FIX: Fix ordering of random post embeddings backfill

* fix annotations

---------

Co-authored-by: Roman Rizzi <[email protected]>
2024-11-27 17:01:54 -03:00
Martin Brennan be05e28679 UX: Add table style for LLM list (#963)
Followup 2f7895bb91,
I forgot to commit this minor change
2024-11-27 13:45:01 +10:00
Martin Brennan 2f7895bb91 UX: Applying more admin UI guidelines (#956)
This commit applies further admin UI guidelines, now that they have been more
fleshed out in core, to the AI admin UI:

* Tools
* LLMs
* Personas

The changes include but are not limited to:

* Applying the table CSS classes, for desktop and mobile
* Adding a description and learn more link for each tab
* Adding an empty list placeholder with CTA using `AdminConfigAreaEmptyList`
* Replacing custom headings with `AdminPageSubheader`
2024-11-27 13:34:56 +10:00
Discourse Translator Bot 7a094cde18 Update translations (#962) 2024-11-26 23:15:10 +01:00
Roman Rizzi 251628bfa1 FIX: Shutdown embeddings thread pool after processing (#961) 2024-11-26 18:12:03 -03:00
Roman Rizzi ef07fcb308 FIX: Skip records without content to classify (#960) 2024-11-26 15:54:20 -03:00
Roman Rizzi ddf2bf7034 DEV: Backfill embeddings concurrently. (#941)
We are adding a new method for generating and storing embeddings in bulk, which relies on `Concurrent::Promises::Future`. Generating an embedding consists of three steps:

Prepare text
HTTP call to retrieve the vector
Save to DB.
Each one is independently executed on whatever thread the pool gives us.

We are bringing a custom thread pool instead of the global executor since we want control over how many threads we spawn to limit concurrency. We also avoid firing thousands of HTTP requests when working with large batches.
2024-11-26 14:12:32 -03:00
Rafael dos Santos Silva 23193ee6f2 FEATURE: Calculate gists from non hot topics too (#958)
Also renames some settings to remove 'hot' references.
2024-11-26 13:44:12 -03:00
Sam 54f2d34ccb DEV: skip flakey spec (#955)
This spec fails inconsistently with:

-fragment-n14
       +You are a helpful Discourse assistant.
       +You _understand_ and **generate** Discourse Markdown.
       +You live in a Discourse Forum Message.
       +
       +You live in the forum with the URL: http://test.localhost
       +The title of your site: test site title
       +The description is: test site description
       +The participants in this conversation are: joe, jane
       +The date now is: 2024-11-25 20:23:02 UTC, much has changed since you were trained.
       +
       +You were trained on OLD data, lean on search to get up to date information about this forum
       +When searching try to SIMPLIFY search terms
       +Discourse search joins all terms with AND. Reduce and simplify terms to find more results.<guidance>
       +The following texts will give you additional guidance for your response.
       +We included them because we believe they are relevant to this conversation topic.
       +
       +Texts:
       +
       +fragment-n10
       +fragment-n9
       +fragment-n8
       +fragment-n7
       +fragment-n6
       +fragment-n5
       +fragment-n4
       +fragment-n3
       +fragment-n2
       +fragment-n1
       +</guidance>
2024-11-26 07:49:33 +11:00
Sam 616b990894 FEATURE: LLM mentions and auto silence (#949)
* FEATURE: allow mentioning an LLM mid conversation to switch

This is a edgecase feature that allow you to start a conversation
in a PM with LLM1 and then use LLM2 to evaluation or continue
the conversation

* FEATURE: allow auto silencing of spam accounts

New rule can also allow for silencing an account automatically

This can prevent spammers from creating additional posts.
2024-11-26 07:19:56 +11:00
129 changed files with 3360 additions and 1119 deletions
@@ -1,18 +1,18 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
queryParams: {
export default class AdminPluginsShowDiscourseAiLlmsNew extends DiscourseRoute {
queryParams = {
llmTemplate: { refreshModel: true },
},
};
async model() {
const record = this.store.createRecord("ai-llm");
record.provider_params = {};
return record;
},
}
setupController(controller, model) {
this._super(controller, model);
super.setupController(controller, model);
controller.set(
"allLlms",
this.modelFor("adminPlugins.show.discourse-ai-llms")
@@ -21,5 +21,5 @@ export default DiscourseRoute.extend({
"llmTemplate",
this.paramsFor(this.routeName).llmTemplate || null
);
},
});
}
}
@@ -1,19 +1,19 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
export default class AdminPluginsShowDiscourseAiLlmsShow extends DiscourseRoute {
async model(params) {
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
const id = parseInt(params.id, 10);
const record = allLlms.findBy("id", id);
record.provider_params = record.provider_params || {};
return record;
},
}
setupController(controller, model) {
this._super(controller, model);
super.setupController(controller, model);
controller.set(
"allLlms",
this.modelFor("adminPlugins.show.discourse-ai-llms")
);
},
});
}
}
@@ -1,7 +1,7 @@
import { AUTO_GROUPS } from "discourse/lib/constants";
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
export default class AdminPluginsShowDiscourseAiPersonasNew extends DiscourseRoute {
async model() {
const record = this.store.createRecord("ai-persona");
record.set("allowed_group_ids", [AUTO_GROUPS.trust_level_0.id]);
@@ -13,13 +13,13 @@ export default DiscourseRoute.extend({
record.set("allow_personal_messages", true);
record.set("tool_details", false);
return record;
},
}
setupController(controller, model) {
this._super(controller, model);
super.setupController(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
},
});
}
}
@@ -1,19 +1,19 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
export default class AdminPluginsShowDiscourseAiPersonasShow extends DiscourseRoute {
async model(params) {
const allPersonas = this.modelFor(
"adminPlugins.show.discourse-ai-personas"
);
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
},
}
setupController(controller, model) {
this._super(controller, model);
super.setupController(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
},
});
}
}
@@ -0,0 +1,11 @@
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiUsageRoute extends DiscourseRoute {
@service store;
model() {
return ajax("/admin/plugins/discourse-ai/ai-usage.json");
}
}
@@ -1,5 +1,7 @@
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
</section>
@@ -1,5 +1,7 @@
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
</section>
@@ -0,0 +1 @@
<AiUsage @model={{this.model}} />
@@ -0,0 +1,27 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiUsageController < ::Admin::AdminController
requires_plugin "discourse-ai"
def show
render json: AiUsageSerializer.new(create_report, root: false)
end
private
def create_report
report =
DiscourseAi::Completions::Report.new(
start_date: params[:start_date]&.to_date || 30.days.ago,
end_date: params[:end_date]&.to_date || Time.current,
)
report = report.filter_by_feature(params[:feature]) if params[:feature].present?
report = report.filter_by_model(params[:model]) if params[:model].present?
report
end
end
end
end
@@ -35,7 +35,7 @@ module DiscourseAi
prompt.custom_instruction = params[:custom_prompt]
end
suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
hijack do
render json:
@@ -1,23 +1,20 @@
# frozen_string_literal: true
module ::Jobs
class UpdateHotTopicGist < ::Jobs::Base
class FastTrackTopicGist < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_summarization_enabled
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
return if !SiteSetting.ai_summary_gists_enabled
topic = Topic.find_by(id: args[:topic_id])
return if topic.blank?
return if !TopicHotScore.where(topic: topic).exists?
summarizer = DiscourseAi::Summarization.topic_gist(topic)
gist = summarizer.existing_summary
return if gist.blank?
return if !gist.outdated
return if gist.present? && !gist.outdated
summarizer.force_summarize(Discourse.system_user)
end
-22
View File
@@ -1,22 +0,0 @@
# frozen_string_literal: true
module ::Jobs
class HotTopicsGistBatch < ::Jobs::Base
def execute(_args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_summarization_enabled
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
Topic
.joins("JOIN topic_hot_scores on topics.id = topic_hot_scores.topic_id")
.order("topic_hot_scores.score DESC")
.limit(SiteSetting.ai_summarize_max_hot_topics_gists_per_batch)
.each do |topic|
summarizer = DiscourseAi::Summarization.topic_gist(topic)
gist = summarizer.existing_summary
summarizer.force_summarize(Discourse.system_user) if gist.blank? || gist.outdated
end
end
end
end
+1 -3
View File
@@ -9,9 +9,7 @@ module ::Jobs
post = Post.find_by(id: post_id, post_type: Post.types[:regular])
return if post&.raw.blank?
DiscourseAi::PostClassificator.new(
DiscourseAi::Sentiment::SentimentClassification.new,
).classify!(post)
DiscourseAi::Sentiment::PostClassification.new.classify!(post)
end
end
end
+25 -21
View File
@@ -70,14 +70,15 @@ module Jobs
Post
.joins("LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id")
.where(deleted_at: nil)
.where(post_type: Post.types[:regular])
.limit(limit - rebaked)
# First, we'll try to backfill embeddings for posts that have none
posts
.where("#{table_name}.post_id IS NULL")
.find_each do |t|
vector_rep.generate_representation_from(t)
rebaked += 1
.find_in_batches do |batch|
vector_rep.gen_bulk_reprensentations(batch)
rebaked += batch.size
end
return if rebaked >= limit
@@ -90,24 +91,28 @@ module Jobs
OR
#{table_name}.strategy_version < #{strategy.version}
SQL
.find_each do |t|
vector_rep.generate_representation_from(t)
rebaked += 1
.find_in_batches do |batch|
vector_rep.gen_bulk_reprensentations(batch)
rebaked += batch.size
end
return if rebaked >= limit
# Finally, we'll try to backfill embeddings for posts that have outdated
# embeddings due to edits. Here we only do 10% of the limit
posts
.where("#{table_name}.updated_at < ?", 7.days.ago)
.order("random()")
.limit((limit - rebaked) / 10)
.pluck(:id)
.each do |id|
vector_rep.generate_representation_from(Post.find_by(id: id))
rebaked += 1
end
posts_batch_size = 1000
outdated_post_ids =
posts
.where("#{table_name}.updated_at < ?", 7.days.ago)
.order("random()")
.limit((limit - rebaked) / 10)
.pluck(:id)
outdated_post_ids.each_slice(posts_batch_size) do |batch|
vector_rep.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
rebaked
end
@@ -120,14 +125,13 @@ module Jobs
topics = topics.where("#{vector_rep.topic_table_name}.topic_id IS NULL") if !force
ids = topics.pluck("topics.id")
batch_size = 1000
ids.each do |id|
topic = Topic.find_by(id: id)
if topic
vector_rep.generate_representation_from(topic)
done += 1
end
ids.each_slice(batch_size) do |batch|
vector_rep.gen_bulk_reprensentations(Topic.where(id: batch).order("topics.bumped_at DESC"))
done += batch.length
end
done
end
end
+5 -3
View File
@@ -19,6 +19,8 @@ module ::Jobs
DiscourseAi::Summarization.topic_summary(topic).force_summarize(system_user)
end
return unless SiteSetting.ai_summary_gists_enabled
gist_t = AiSummary.summary_types[:gist]
backfill_candidates(gist_t)
.limit(current_budget(gist_t))
@@ -29,9 +31,9 @@ module ::Jobs
Topic
.where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count)
.joins(<<~SQL)
LEFT OUTER JOIN ai_summaries ais ON
topics.id = ais.target_id AND
ais.target_type = 'Topic' AND
LEFT OUTER JOIN ai_summaries ais ON
topics.id = ais.target_id AND
ais.target_type = 'Topic' AND
ais.summary_type = '#{summary_type}'
SQL
.where(
+8
View File
@@ -3,6 +3,7 @@
class AiApiAuditLog < ActiveRecord::Base
belongs_to :post
belongs_to :topic
belongs_to :user
module Provider
OpenAI = 1
@@ -43,3 +44,10 @@ end
# feature_name :string(255)
# language_model :string(255)
# feature_context :jsonb
# cached_tokens :integer
#
# Indexes
#
# index_ai_api_audit_logs_on_created_at_and_feature_name (created_at,feature_name)
# index_ai_api_audit_logs_on_created_at_and_language_model (created_at,language_model)
#
+17 -9
View File
@@ -9,15 +9,23 @@ class AiSummary < ActiveRecord::Base
def self.store!(strategy, llm_model, summary, og_content, human:)
content_ids = og_content.map { |c| c[:id] }
AiSummary.create!(
target: strategy.target,
algorithm: llm_model.name,
content_range: (content_ids.first..content_ids.last),
summarized_text: summary,
original_content_sha: build_sha(content_ids.join),
summary_type: strategy.type,
origin: !!human ? origins[:human] : origins[:system],
)
AiSummary
.upsert(
{
target_id: strategy.target.id,
target_type: strategy.target.class.name,
algorithm: llm_model.name,
content_range: (content_ids.first..content_ids.last),
summarized_text: summary,
original_content_sha: build_sha(content_ids.join),
summary_type: strategy.type,
origin: !!human ? origins[:human] : origins[:system],
},
unique_by: %i[target_id target_type summary_type],
update_only: %i[summarized_text original_content_sha algorithm origin content_range],
)
.first
.then { AiSummary.find_by(id: _1["id"]) }
end
def self.build_sha(joined_ids)
+1
View File
@@ -172,6 +172,7 @@ end
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
#
# Indexes
#
+1
View File
@@ -229,6 +229,7 @@ end
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
#
# Indexes
#
+69
View File
@@ -0,0 +1,69 @@
# frozen_string_literal: true
class AiUsageSerializer < ApplicationSerializer
attributes :data, :features, :models, :users, :summary, :period
def data
object.tokens_by_period.as_json(
only: %i[period total_tokens total_cached_tokens total_request_tokens total_response_tokens],
)
end
def period
object.guess_period
end
def features
object.feature_breakdown.as_json(
only: %i[
feature_name
usage_count
total_tokens
total_cached_tokens
total_request_tokens
total_response_tokens
],
)
end
def models
object.model_breakdown.as_json(
only: %i[
llm
usage_count
total_tokens
total_cached_tokens
total_request_tokens
total_response_tokens
],
)
end
def users
object.user_breakdown.map do |user|
{
avatar_template: User.avatar_template(user.username, user.uploaded_avatar_id),
username: user.username,
usage_count: user.usage_count,
total_tokens: user.total_tokens,
total_cached_tokens: user.total_cached_tokens,
total_request_tokens: user.total_request_tokens,
total_response_tokens: user.total_response_tokens,
}
end
end
def summary
{
total_tokens: object.total_tokens,
total_cached_tokens: object.total_cached_tokens,
total_request_tokens: object.total_request_tokens,
total_response_tokens: object.total_response_tokens,
total_requests: object.total_requests,
date_range: {
start: object.start_date,
end: object.end_date,
},
}
end
end
@@ -18,5 +18,6 @@ export default {
this.route("new");
this.route("show", { path: "/:id" });
});
this.route("discourse-ai-usage", { path: "ai-usage" });
},
};
@@ -1,22 +1,25 @@
import { computed, observer } from "@ember/object";
import { computed } from "@ember/object";
import { observes } from "@ember-decorators/object";
import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import { selectKitOptions } from "select-kit/components/select-kit";
export default ComboBox.extend({
_modelDisabledChanged: observer("attrs.disabled", function () {
@selectKitOptions({
filterable: true,
})
export default class AiLlmSelector extends ComboBox {
@observes("attrs.disabled")
_modelDisabledChanged() {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}),
}
content: computed(function () {
@computed
get content() {
return [
{
id: "blank",
name: I18n.t("discourse_ai.ai_persona.no_llm_selected"),
},
].concat(this.llms);
}),
selectKitOptions: {
filterable: true,
},
});
}
}
@@ -4,7 +4,6 @@ import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminPageSubheader from "admin/components/admin-page-subheader";
@@ -124,8 +123,10 @@ export default class AiLlmsListEditor extends Component {
<section class="ai-llms-list-editor__configured">
<AdminPageSubheader
@titleLabel="discourse_ai.llms.configured.title"
@descriptionLabel="discourse_ai.llms.preconfigured.description"
@learnMoreUrl="https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
/>
<table>
<table class="d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.llms.display_name"}}</th>
@@ -135,12 +136,19 @@ export default class AiLlmsListEditor extends Component {
</thead>
<tbody>
{{#each @llms as |llm|}}
<tr data-llm-id={{llm.name}} class="ai-llm-list__row">
<td class="column-name">
<h3>{{llm.display_name}}</h3>
<p>
<tr
data-llm-id={{llm.name}}
class="ai-llm-list__row d-admin-row__content"
>
<td class="d-admin-row__overview">
<div class="ai-llm-list__name">
<strong>
{{llm.display_name}}
</strong>
</div>
<div class="ai-llm-list__description">
{{this.modelDescription llm}}
</p>
</div>
{{#if llm.used_by}}
<ul class="ai-llm-list-editor__usages">
{{#each llm.used_by as |usage|}}
@@ -149,18 +157,20 @@ export default class AiLlmsListEditor extends Component {
</ul>
{{/if}}
</td>
<td class="column-provider">
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_ai.llms.provider"}}
</div>
{{i18n
(concat "discourse_ai.llms.providers." llm.provider)
}}
</td>
<td class="column-edit">
<td class="d-admin-row__controls">
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.show"
class="btn btn-default"
class="btn btn-default btn-small ai-llm-list__edit-button"
@model={{llm.id}}
>
{{icon "wrench"}}
<div class="d-button-label">
{{i18n "discourse_ai.llms.edit"}}
</div>
@@ -173,7 +183,17 @@ export default class AiLlmsListEditor extends Component {
</section>
{{/if}}
<section class="ai-llms-list-editor__templates">
<AdminPageSubheader @titleLabel={{this.preconfiguredTitle}} />
<AdminPageSubheader
@titleLabel={{this.preconfiguredTitle}}
@descriptionLabel={{unless
this.hasLlmElements
"discourse_ai.llms.preconfigured.description"
}}
@learnMoreUrl={{unless
this.hasLlmElements
"https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
}}
/>
<AdminSectionLandingWrapper
class="ai-llms-list-editor__templates-list"
@@ -1,5 +1,4 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
@@ -9,26 +8,13 @@ import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import concatClass from "discourse/helpers/concat-class";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { cook } from "discourse/lib/text";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import AdminPageSubheader from "admin/components/admin-page-subheader";
import AiPersonaEditor from "./ai-persona-editor";
export default class AiPersonaListEditor extends Component {
@service adminPluginNavManager;
@tracked _noPersonaText = null;
get noPersonaText() {
if (this._noPersonaText === null) {
const raw = I18n.t("discourse_ai.ai_persona.no_persona_selected");
cook(raw).then((result) => {
this._noPersonaText = result;
});
}
return this._noPersonaText;
}
@action
async toggleEnabled(persona) {
@@ -53,74 +39,76 @@ export default class AiPersonaListEditor extends Component {
{{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}}
<div class="ai-persona-list-editor__header">
<h3>{{i18n "discourse_ai.ai_persona.short_title"}}</h3>
{{#unless @currentPersona.isNew}}
<LinkTo
<AdminPageSubheader
@titleLabel="discourse_ai.ai_persona.short_title"
@descriptionLabel="discourse_ai.ai_persona.persona_description"
@learnMoreUrl="https://meta.discourse.org/t/ai-bot-personas/306099"
>
<:actions as |actions|>
<actions.Primary
@label="discourse_ai.ai_persona.new"
@route="adminPlugins.show.discourse-ai-personas.new"
class="btn btn-small btn-primary"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
</LinkTo>
{{/unless}}
</div>
@icon="plus"
class="ai-persona-list-editor__new-button"
/>
</:actions>
</AdminPageSubheader>
<div class="ai-persona-list-editor__empty">
<details class="details__boxed">
<summary>{{i18n
"discourse_ai.ai_persona.what_are_personas"
}}</summary>
{{this.noPersonaText}}
</details>
</div>
<table class="content-list ai-persona-list-editor">
<thead>
<tr>
<th>{{i18n "discourse_ai.ai_persona.name"}}</th>
<th>{{i18n "discourse_ai.ai_persona.enabled"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each @personas as |persona|}}
<tr
data-persona-id={{persona.id}}
class={{concatClass
"ai-persona-list__row"
(if persona.priority "priority")
}}
>
<td>
<div class="ai-persona-list__name-with-description">
<div class="ai-persona-list__name">
<strong>
{{persona.name}}
</strong>
</div>
<div class="ai-persona-list__description">
{{persona.description}}
</div>
</div>
</td>
<td>
<DToggleSwitch
@state={{persona.enabled}}
{{on "click" (fn this.toggleEnabled persona)}}
/>
</td>
<td>
<LinkTo
@route="adminPlugins.show.discourse-ai-personas.show"
@model={{persona}}
class="btn btn-text btn-small"
>{{i18n "discourse_ai.ai_persona.edit"}} </LinkTo>
</td>
{{#if @personas}}
<table class="content-list ai-persona-list-editor d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.ai_persona.name"}}</th>
<th>{{i18n "discourse_ai.ai_persona.enabled"}}</th>
<th></th>
</tr>
{{/each}}
</tbody>
</table>
</thead>
<tbody>
{{#each @personas as |persona|}}
<tr
data-persona-id={{persona.id}}
class={{concatClass
"ai-persona-list__row d-admin-row__content"
(if persona.priority "priority")
}}
>
<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>
<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>
<td class="d-admin-row__controls">
<LinkTo
@route="adminPlugins.show.discourse-ai-personas.show"
@model={{persona}}
class="btn btn-text btn-small"
>{{i18n "discourse_ai.ai_persona.edit"}} </LinkTo>
</td>
</tr>
{{/each}}
</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"
/>
{{/if}}
{{/if}}
</section>
</template>
@@ -54,8 +54,13 @@ export default class AiSplitTopicSuggester extends Component {
suggestions.includes(item.name.toLowerCase())
);
this.suggestions = suggestedCategories;
} else {
this.suggestions = result.assistant.map((s) => s.name);
} else if (this.args.mode === this.SUGGESTION_TYPES.tag) {
this.suggestions = result.assistant.map((s) => {
return {
name: s.name,
count: s.count,
};
});
}
})
.catch(popupAjaxError)
@@ -132,6 +137,17 @@ export default class AiSplitTopicSuggester extends Component {
{{on "click" (fn this.applySuggestion suggestion menu)}}
>
{{categoryBadge suggestion}}
<span class="topic-count">x
{{suggestion.totalTopicCount}}</span>
</li>
{{else if (eq @mode "suggest_tags")}}
<li data-name={{suggestion.name}} data-value={{index}}>
<DButton
@translatedLabel={{suggestion.name}}
@action={{fn this.applySuggestion suggestion.name menu}}
>
<span class="topic-count">x{{suggestion.count}}</span>
</DButton>
</li>
{{else}}
<li data-name={{suggestion}} data-value={{index}}>
@@ -1,249 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse-common/utils/decorators";
import I18n from "I18n";
export default class AISuggestionDropdown extends Component {
@service dialog;
@service siteSettings;
@service composer;
@tracked loading = false;
@tracked showMenu = false;
@tracked generatedSuggestions = [];
@tracked suggestIcon = "discourse-sparkles";
@tracked showErrors = false;
@tracked error = "";
SUGGESTION_TYPES = {
title: "suggest_title",
category: "suggest_category",
tag: "suggest_tags",
};
willDestroy() {
super.willDestroy(...arguments);
document.removeEventListener("click", this.onClickOutside);
}
get showAIButton() {
const minCharacterCount = 40;
const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
const composerFields = document.querySelector(".composer-fields");
if (composerFields) {
if (isShowAIButton) {
composerFields.classList.add("showing-ai-suggestions");
} else {
composerFields.classList.remove("showing-ai-suggestions");
}
}
return isShowAIButton;
}
get disableSuggestionButton() {
return this.loading;
}
@action
applyClasses() {
if (this.showAIButton) {
document
.querySelector(".composer-fields")
?.classList.add("showing-ai-suggestions");
} else {
document
.querySelector(".composer-fields")
?.classList.remove("showing-ai-suggestions");
}
}
@bind
onClickOutside(event) {
const menu = document.querySelector(".ai-title-suggestions-menu");
if (event.target === menu) {
return;
}
return this.#closeMenu();
}
@action
handleClickOutside() {
document.addEventListener("click", this.onClickOutside);
}
@action
applySuggestion(suggestion) {
if (!this.args.mode) {
return;
}
const composer = this.args?.composer;
if (!composer) {
return;
}
if (this.args.mode === this.SUGGESTION_TYPES.title) {
composer.set("title", suggestion);
return this.#closeMenu();
}
if (this.args.mode === this.SUGGESTION_TYPES.category) {
const selectedCategoryId = this.composer.categories.find(
(c) => c.slug === suggestion
).id;
composer.set("categoryId", selectedCategoryId);
return this.#closeMenu();
}
if (this.args.mode === this.SUGGESTION_TYPES.tag) {
this.#updateTags(suggestion, composer);
}
}
@action
async performSuggestion() {
if (!this.args.mode) {
return;
}
if (this.composer.model.replyLength === 0) {
return this.dialog.alert(
I18n.t("discourse_ai.ai_helper.missing_content")
);
}
this.loading = true;
this.suggestIcon = "spinner";
return ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
method: "POST",
data: { text: this.composer.model.reply },
})
.then((data) => {
this.#assignGeneratedSuggestions(data, this.args.mode);
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
this.suggestIcon = "sync-alt";
this.showMenu = true;
if (this.args.mode === "suggest_category") {
document
.querySelector(".category-input")
?.classList.add("showing-ai-suggestion-menu");
}
});
}
#closeMenu() {
if (this.showMenu && this.args.mode === "suggest_category") {
document
.querySelector(".category-input")
?.classList.remove("showing-ai-suggestion-menu");
}
this.suggestIcon = "discourse-sparkles";
this.showMenu = false;
this.showErrors = false;
this.errors = "";
}
#updateTags(suggestion, composer) {
const maxTags = this.siteSettings.max_tags_per_topic;
if (!composer.tags) {
composer.set("tags", [suggestion]);
// remove tag from the list of suggestions once added
this.generatedSuggestions = this.generatedSuggestions.filter(
(s) => s !== suggestion
);
return;
}
const tags = composer.tags;
if (tags?.length >= maxTags) {
// Show error if trying to add more tags than allowed
this.showErrors = true;
this.error = I18n.t("select_kit.max_content_reached", { count: maxTags });
return;
}
tags.push(suggestion);
composer.set("tags", [...tags]);
// remove tag from the list of suggestions once added
return (this.generatedSuggestions = this.generatedSuggestions.filter(
(s) => s !== suggestion
));
}
#tagSelectorHasValues() {
return this.args.composer?.tags && this.args.composer?.tags.length > 0;
}
#assignGeneratedSuggestions(data, mode) {
if (mode === this.SUGGESTION_TYPES.title) {
return (this.generatedSuggestions = data.suggestions);
}
const suggestions = data.assistant.map((s) => s.name);
if (mode === this.SUGGESTION_TYPES.tag) {
if (this.#tagSelectorHasValues()) {
// Filter out tags if they are already selected in the tag input
return (this.generatedSuggestions = suggestions.filter(
(t) => !this.args.composer.tags.includes(t)
));
} else {
return (this.generatedSuggestions = suggestions);
}
}
return (this.generatedSuggestions = suggestions);
}
<template>
{{#if this.showAIButton}}
<DButton
@icon={{this.suggestIcon}}
@title="discourse_ai.ai_helper.suggest"
@action={{this.performSuggestion}}
@disabled={{this.disableSuggestionButton}}
class="suggestion-button {{if this.loading 'is-loading'}}"
...attributes
/>
{{/if}}
{{#if this.showMenu}}
{{! template-lint-disable modifier-name-case }}
<ul
class="popup-menu ai-suggestions-menu"
{{didInsert this.handleClickOutside}}
>
{{#if this.showErrors}}
<li class="ai-suggestions-menu__errors">{{this.error}}</li>
{{/if}}
{{#each this.generatedSuggestions as |suggestion index|}}
<li data-name={{suggestion}} data-value={{index}}>
<DButton
@translatedLabel={{suggestion}}
@action={{fn this.applySuggestion suggestion}}
class="popup-menu-btn"
/>
</li>
{{/each}}
</ul>
{{/if}}
</template>
}
@@ -2,9 +2,10 @@ import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import AdminPageSubheader from "admin/components/admin-page-subheader";
export default class AiToolListEditor extends Component {
@service adminPluginNavManager;
@@ -15,44 +16,64 @@ export default class AiToolListEditor extends Component {
@label={{i18n "discourse_ai.tools.short_title"}}
/>
<section class="ai-tool-list-editor__current admin-detail pull-left">
<div class="ai-tool-list-editor__header">
<h3>{{I18n.t "discourse_ai.tools.short_title"}}</h3>
<LinkTo
@route="adminPlugins.show.discourse-ai-tools.new"
class="btn btn-small btn-primary ai-tool-list-editor__new-button"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.tools.new"}}</span>
</LinkTo>
</div>
<AdminPageSubheader
@titleLabel="discourse_ai.tools.short_title"
@learnMoreUrl="https://meta.discourse.org/t/ai-bot-custom-tools/314103"
@descriptionLabel="discourse_ai.tools.subheader_description"
>
<:actions as |actions|>
<actions.Primary
@label="discourse_ai.tools.new"
@route="adminPlugins.show.discourse-ai-tools.new"
@icon="plus"
class="ai-tool-list-editor__new-button"
/>
</:actions>
</AdminPageSubheader>
<table class="content-list ai-tool-list-editor">
<tbody>
{{#each @tools as |tool|}}
<tr data-tool-id={{tool.id}} class="ai-tool-list__row">
<td>
<div class="ai-tool-list__name-with-description">
<div class="ai-tool-list__name">
<strong>
{{tool.name}}
</strong>
{{#if @tools}}
<table class="d-admin-table ai-tool-list-editor">
<thead>
<th>{{i18n "discourse_ai.tools.name"}}</th>
<th></th>
</thead>
<tbody>
{{#each @tools as |tool|}}
<tr
data-tool-id={{tool.id}}
class="ai-tool-list__row d-admin-row__content"
>
<td class="d-admin-row__overview">
<div class="ai-tool-list__name-with-description">
<div class="ai-tool-list__name">
<strong>
{{tool.name}}
</strong>
</div>
<div class="ai-tool-list__description">
{{tool.description}}
</div>
</div>
<div class="ai-tool-list__description">
{{tool.description}}
</div>
</div>
</td>
<td>
<LinkTo
@route="adminPlugins.show.discourse-ai-tools.show"
@model={{tool}}
class="btn btn-text btn-small"
>{{I18n.t "discourse_ai.tools.edit"}}</LinkTo>
</td>
</tr>
{{/each}}
</tbody>
</table>
</td>
<td class="d-admin-row__controls">
<LinkTo
@route="adminPlugins.show.discourse-ai-tools.show"
@model={{tool}}
class="btn btn-text btn-small"
>{{I18n.t "discourse_ai.tools.edit"}}</LinkTo>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="discourse_ai.tools.new"
@ctaRoute="adminPlugins.show.discourse-ai-tools.new"
@ctaClass="ai-tool-list-editor__empty-new-button"
@emptyLabel="discourse_ai.tools.no_tools"
/>
{{/if}}
</section>
</template>
}
@@ -1,17 +1,18 @@
import { observer } from "@ember/object";
import { readOnly } from "@ember/object/computed";
import { observes } from "@ember-decorators/object";
import MultiSelectComponent from "select-kit/components/multi-select";
import { selectKitOptions } from "select-kit/components/select-kit";
export default MultiSelectComponent.extend({
_modelDisabledChanged: observer("attrs.disabled", function () {
@selectKitOptions({
filterable: true,
})
export default class AiToolSelector extends MultiSelectComponent {
@readOnly("tools") content;
value = "";
@observes("attrs.disabled")
_modelDisabledChanged() {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}),
content: readOnly("tools"),
value: "",
selectKitOptions: {
filterable: true,
},
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import categoryBadge from "discourse/helpers/category-badge";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import DMenu from "float-kit/components/d-menu";
import { MIN_CHARACTER_COUNT } from "../../lib/ai-helper-suggestions";
export default class AiCategorySuggester extends Component {
@service siteSettings;
@tracked loading = false;
@tracked suggestions = null;
@tracked untriggers = [];
@tracked triggerIcon = "discourse-sparkles";
@tracked content = null;
@tracked topicContent = null;
constructor() {
super(...arguments);
if (!this.topicContent && this.args.composer?.reply === undefined) {
this.fetchTopicContent();
}
}
async fetchTopicContent() {
await ajax(`/t/${this.args.buffered.content.id}.json`).then(
({ post_stream }) => {
this.topicContent = post_stream.posts[0].cooked;
}
);
}
get showSuggestionButton() {
const composerFields = document.querySelector(".composer-fields");
this.content = this.args.composer?.reply || this.topicContent;
const showTrigger = this.content?.length > MIN_CHARACTER_COUNT;
if (composerFields) {
if (showTrigger) {
composerFields.classList.add("showing-ai-suggestions");
} else {
composerFields.classList.remove("showing-ai-suggestions");
}
}
return this.siteSettings.ai_embeddings_enabled && showTrigger;
}
@action
async loadSuggestions() {
if (this.suggestions && !this.dMenu.expanded) {
return this.suggestions;
}
this.loading = true;
this.triggerIcon = "spinner";
try {
const { assistant } = await ajax(
"/discourse-ai/ai-helper/suggest_category",
{
method: "POST",
data: { text: this.content },
}
);
this.suggestions = assistant;
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
this.triggerIcon = "sync-alt";
}
return this.suggestions;
}
@action
applySuggestion(suggestion) {
const composer = this.args.composer;
const buffered = this.args.buffered;
if (composer) {
composer.set("categoryId", suggestion.id);
composer.get("categoryId");
}
if (buffered) {
this.args.buffered.set("category_id", suggestion.id);
}
return this.dMenu.close();
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
onClose() {
this.triggerIcon = "discourse-sparkles";
}
<template>
{{#if this.showSuggestionButton}}
<DMenu
@title={{i18n "discourse_ai.ai_helper.suggest"}}
@icon={{this.triggerIcon}}
@identifier="ai-category-suggester"
@onClose={{this.onClose}}
@triggerClass="suggestion-button suggest-category-button {{if
this.loading
'is-loading'
}}"
@contentClass="ai-suggestions-menu"
@onRegisterApi={{this.onRegisterApi}}
@modalForMobile={{true}}
@untriggers={{this.untriggers}}
{{on "click" this.loadSuggestions}}
>
<:content>
{{#unless this.loading}}
<DropdownMenu as |dropdown|>
{{#each this.suggestions as |suggestion index|}}
<dropdown.item>
<DButton
class="category-row"
data-name={{suggestion.name}}
data-value={{index}}
title={{suggestion.name}}
@action={{fn this.applySuggestion suggestion}}
>
<div class="category-status">
{{categoryBadge suggestion}}
<span class="topic-count">x
{{suggestion.topicCount}}</span>
</div>
</DButton>
</dropdown.item>
{{/each}}
</DropdownMenu>
{{/unless}}
</:content>
</DMenu>
{{/if}}
</template>
}

Some files were not shown because too many files have changed in this diff Show More