Compare commits

...
Author SHA1 Message Date
Roman Rizzi f1598b4679 FIX: Use COPY to avoid migration statement timeout 2025-01-14 12:23:49 -03:00
Roman Rizzi 65bbcd71fc DEV: Embedding tables' model_id has to be a bigint (#1058)
* DEV: Embedding tables' model_id has to be a bigint

* Drop old search_bit indexes

* copy rag fragment embeddings created during deploy window
2025-01-14 10:53:06 -03:00
SamandKeegan George d07cf51653 FEATURE: llm quotas (#1047)
Adds a comprehensive quota management system for LLM models that allows:

- Setting per-group (applied per user in the group) token and usage limits with configurable durations
- Tracking and enforcing token/usage limits across user groups
- Quota reset periods (hourly, daily, weekly, or custom)
-  Admin UI for managing quotas with real-time updates

This system provides granular control over LLM API usage by allowing admins
to define limits on both total tokens and number of requests per group.
Supports multiple concurrent quotas per model and automatically handles
quota resets.


Co-authored-by: Keegan George <[email protected]>
2025-01-14 15:54:09 +11:00
Sam 20612fde52 FEATURE: add the ability to disable streaming on an Open AI LLM
Disabling streaming is required for models such o1 that do not have streaming
enabled yet

It is good to carry this feature around in case various apis decide not to support streaming endpoints and Discourse AI can continue to work just as it did before. 

Also: fixes issue where sharing artifacts would miss viewport leading to tiny artifacts on mobile
2025-01-13 17:01:01 +11:00
Jarek Radosz 7e9c0dc076 FIX: Invalid locale yaml (#1057) 2025-01-11 23:31:33 +01:00
Mark VanLandingham 2d1ce01320 DEV: Add no-results className to full page search toggle (#1055) 2025-01-10 10:59:18 -06:00
Keegan George b24669c810 DEV: Add structure for errors in spam (#1054)
This update adds some structure for handling errors in the spam config while also handling a specific error related to the spam scanning user not being an admin account.
2025-01-09 09:17:06 -08:00
Keegan George 24b69bf840 FIX: Update spam controller action should consider seeded LLM properly (#1053)
The seeded LLM setting: `SiteSetting.ai_spam_detection_model_allowed_seeded_models` returns a _string_ with IDs separated by pipes. running `_map` on it will return an array with strings. We were previously checking for the id with custom prefix identifier, but instead we should be checking the stringified ID.
2025-01-08 13:41:25 -08:00
Guhyoun Nam 404092a68c DEV: Add appEvents trigger when Ai search results toggled (#1052)
This PR adds appEvents triggers when Ai search results are toggled.
2025-01-08 12:17:25 -06:00
Mark VanLandingham 327adbde29 UX: Full page search -- always show tooltip & add msg (#1051) 2025-01-08 09:05:30 -06:00
Kris 749af40fad UX: close summary modal on click outside (#1050) 2025-01-07 11:24:27 -05:00
Discourse Translator Bot 61758ff8a6 Update translations (#1040) 2025-01-03 14:01:41 +01:00
Mark VanLandingham b6cefd10fa DEV: Move semantic search from connector to component (#1048) 2025-01-02 12:32:49 -06:00
Sam 11d0f60f1e FEATURE: smart date support for AI helper (#1044)
* FEATURE: smart date support for AI helper

This feature allows conversion of human typed in dates and times
to smart "Discourse" timezone friendly dates.

* fix specs and lint

* lint

* address feedback

* add specs
2024-12-31 08:04:25 +11:00
Sam f9f89adac5 FIX: keep track of silence reason when spam detection flags user (#1046)
Previously reason was blank for silencing user
2024-12-27 17:47:16 +11:00
Keegan George b480f13a0f FIX: Prevent LLM enumerator from erroring when spam enabled (#1045)
This PR fixes an issue where LLM enumerator would error out when `SiteSetting.ai_spam_detection = true` but there was no `AiModerationSetting.spam` present.

Typically, we add an `LlmDependencyValidator` for the setting itself, however, since Spam is unique in that it has it's model set in `AiModerationSetting` instead of a `SiteSetting`, we'll add a simple check here to prevent erroring out.
2024-12-27 09:12:29 +11:00
Sam 47ecf86aa1 FIX: embedding validation (#1043) 2024-12-24 09:37:23 +11:00
Rafael dos Santos Silva 792df58fbc FIX: AI Helper category / tag suggestion when user does not categories muted (#1042) 2024-12-23 15:58:26 -03:00
Roman Rizzi ceac6e5efb FIX: Embeddings validator test needs to use the new Vector class. (#1041) 2024-12-23 14:19:22 -03:00
Keegan George bdb8f1d5e0 FIX: Custom prefix causing allowed seeded LLMs not to be shown (#1039)
* FIX: Custom prefix causing allowed seeded LLMs not to be shown

* DEV: update spec

* not `_map` so should be string not array
2024-12-23 16:42:26 +11:00
Kris d15876025f UX: disabled preseeded edit button, add description (#1038) 2024-12-20 19:33:45 -05:00
Rafael dos Santos Silva 7607477ff9 FIX: Cloudflare Workers AI embeddings (#1037)
Regressed on 534b0df
2024-12-20 17:45:27 -03:00
Keegan George 059b3fabb8 DEV: Unreachable LLM error shouldn't prevent setting (#1036)
Previously we had the behaviour for model settings so that when you try and set a model, it runs a test and returns an error if it can't run the test successfully. The error then prevents you from setting the site setting.

This results in some issues when we try and automate things. This PR updates that so that the test runs and discreetly logs the changes, but doesn't prevent the setting from being set. Instead we rely on "run test" in the LLM config along with ProblemChecks to catch issues.
2024-12-20 11:52:11 -08:00
Sam 6a7a45fd4f FIX: properly spin down unused streamer threads (#1035)
Previous version was prone to the bug:

https://github.com/ruby-concurrency/concurrent-ruby/issues/1075

This is particularly bad cause we could have a DB connection
attached to the thread and we never clear it up, so after N hours
this could start exhibiting weird connection issues.
2024-12-20 12:09:42 +11:00
Kris ac705b694b UX: minor improvements to LLM page and admin tables (#1034) 2024-12-19 18:14:22 -05:00
90 changed files with 2992 additions and 278 deletions
+1
View File
@@ -1,3 +1,4 @@
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiLlmQuotasController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
quotas = LlmQuota.includes(:group)
render json: {
quotas:
ActiveModel::ArraySerializer.new(quotas, each_serializer: LlmQuotaSerializer),
}
end
def create
quota = LlmQuota.new(quota_params)
if quota.save
render json: LlmQuotaSerializer.new(quota), status: :created
else
render_json_error quota
end
end
def update
quota = LlmQuota.find(params[:id])
if quota.update(quota_params)
render json: LlmQuotaSerializer.new(quota)
else
render_json_error quota
end
end
def destroy
quota = LlmQuota.find(params[:id])
quota.destroy!
head :no_content
rescue ActiveRecord::RecordNotFound
render json: { error: I18n.t("not_found") }, status: 404
end
private
def quota_params
params.require(:quota).permit(
:group_id,
:llm_model_id,
:max_tokens,
:max_usages,
:duration_seconds,
)
end
end
end
end
@@ -6,7 +6,7 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
llms = LlmModel.all.order(:display_name)
llms = LlmModel.all.includes(:llm_quotas).order(:display_name)
render json: {
ai_llms:
@@ -40,6 +40,11 @@ module DiscourseAi
def create
llm_model = LlmModel.new(ai_llm_params)
# we could do nested attributes but the mechanics are not ideal leading
# to lots of complex debugging, this is simpler
quota_params.each { |quota| llm_model.llm_quotas.build(quota) } if quota_params
if llm_model.save
llm_model.toggle_companion_user
render json: LlmModelSerializer.new(llm_model), status: :created
@@ -51,6 +56,25 @@ module DiscourseAi
def update
llm_model = LlmModel.find(params[:id])
if params[:ai_llm].key?(:llm_quotas)
if quota_params
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
new_quota_group_ids = quota_params.map { |q| q[:group_id] }
llm_model
.llm_quotas
.where(group_id: existing_quota_group_ids - new_quota_group_ids)
.destroy_all
quota_params.each do |quota_param|
quota = llm_model.llm_quotas.find_or_initialize_by(group_id: quota_param[:group_id])
quota.update!(quota_param)
end
else
llm_model.llm_quotas.destroy_all
end
end
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
end
@@ -110,6 +134,19 @@ module DiscourseAi
private
def quota_params
if params[:ai_llm][:llm_quotas].present?
params[:ai_llm][:llm_quotas].map do |quota|
mapped = {}
mapped[:group_id] = quota[:group_id].to_i
mapped[:max_tokens] = quota[:max_tokens].to_i if quota[:max_tokens].present?
mapped[:max_usages] = quota[:max_usages].to_i if quota[:max_usages].present?
mapped[:duration_seconds] = quota[:duration_seconds].to_i
mapped
end
end
end
def ai_llm_params(updating: nil)
return {} if params[:ai_llm].blank?
@@ -15,7 +15,7 @@ module DiscourseAi
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
if llm_model_id.to_i < 0 &&
!SiteSetting.ai_spam_detection_model_allowed_seeded_models_map.include?(
"custom:#{llm_model_id}",
llm_model_id.to_s,
)
return(
render_json_error(
@@ -86,6 +86,31 @@ module DiscourseAi
render json: result
end
def fix_errors
case params[:error]
when "spam_scanner_not_admin"
begin
DiscourseAi::AiModeration::SpamScanner.fix_spam_scanner_not_admin
render json: success_json
rescue ActiveRecord::RecordInvalid
render_json_error(
I18n.t("discourse_ai.spam_detection.bot_user_update_failed"),
status: :unprocessable_entity,
)
rescue StandardError
render_json_error(
I18n.t("discourse_ai.spam_detection.unexpected"),
status: :internal_server_error,
)
end
else
render_json_error(
I18n.t("discourse_ai.spam_detection.invalid_error_type"),
status: :bad_request,
)
end
end
private
def allowed_params
@@ -57,6 +57,7 @@ module DiscourseAi
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(name)}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover, interactive-widget=resizes-content">
<style>
html, body, iframe {
margin: 0;
+4
View File
@@ -4,6 +4,7 @@ class LlmModel < ActiveRecord::Base
FIRST_BOT_USER_ID = -1200
BEDROCK_PROVIDER_NAME = "aws_bedrock"
has_many :llm_quotas, dependent: :destroy
belongs_to :user
validates :display_name, presence: true, length: { maximum: 100 }
@@ -32,6 +33,7 @@ class LlmModel < ActiveRecord::Base
open_ai: {
organization: :text,
disable_native_tools: :checkbox,
disable_streaming: :checkbox,
},
mistral: {
disable_native_tools: :checkbox,
@@ -51,11 +53,13 @@ class LlmModel < ActiveRecord::Base
ollama: {
disable_system_prompt: :checkbox,
enable_native_tool: :checkbox,
disable_streaming: :checkbox,
},
open_router: {
disable_native_tools: :checkbox,
provider_order: :text,
provider_quantizations: :text,
disable_streaming: :checkbox,
},
}
end
+85
View File
@@ -0,0 +1,85 @@
# frozen_string_literal: true
class LlmQuota < ActiveRecord::Base
self.table_name = "llm_quotas"
belongs_to :group
belongs_to :llm_model
has_many :llm_quota_usages
validates :group_id, presence: true
# we can not validate on create cause it breaks build
validates :llm_model_id, presence: true, on: :update
validates :duration_seconds, presence: true, numericality: { greater_than: 0 }
validates :max_tokens, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
validates :max_usages, numericality: { greater_than: 0, allow_nil: true }
validate :at_least_one_limit
def self.check_quotas!(llm, user)
return true if user.blank?
quotas = joins(:group).where(llm_model: llm).where(group: user.groups)
return true if quotas.empty?
errors =
quotas.map do |quota|
usage = LlmQuotaUsage.find_or_create_for(user: user, llm_quota: quota)
begin
usage.check_quota!
nil
rescue LlmQuotaUsage::QuotaExceededError => e
e
end
end
return if errors.include?(nil)
raise errors.first
end
def self.log_usage(llm, user, input_tokens, output_tokens)
return if user.blank?
quotas = joins(:group).where(llm_model: llm).where(group: user.groups)
quotas.each do |quota|
usage = LlmQuotaUsage.find_or_create_for(user: user, llm_quota: quota)
usage.increment_usage!(input_tokens: input_tokens, output_tokens: output_tokens)
end
end
def available_tokens
max_tokens
end
def available_usages
max_usages
end
private
def at_least_one_limit
if max_tokens.nil? && max_usages.nil?
errors.add(:base, I18n.t("discourse_ai.errors.quota_required"))
end
end
end
# == Schema Information
#
# Table name: llm_quotas
#
# id :bigint not null, primary key
# group_id :bigint not null
# llm_model_id :bigint not null
# max_tokens :integer
# max_usages :integer
# duration_seconds :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_llm_quotas_on_group_id_and_llm_model_id (group_id,llm_model_id) UNIQUE
# index_llm_quotas_on_llm_model_id (llm_model_id)
#
+120
View File
@@ -0,0 +1,120 @@
# frozen_string_literal: true
class LlmQuotaUsage < ActiveRecord::Base
self.table_name = "llm_quota_usages"
QuotaExceededError = Class.new(StandardError)
belongs_to :user
belongs_to :llm_quota
validates :user_id, presence: true
validates :llm_quota_id, presence: true
validates :input_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :output_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :usages, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :started_at, presence: true
validates :reset_at, presence: true
def self.find_or_create_for(user:, llm_quota:)
usage = find_or_initialize_by(user: user, llm_quota: llm_quota)
if usage.new_record?
now = Time.current
usage.started_at = now
usage.reset_at = now + llm_quota.duration_seconds.seconds
usage.input_tokens_used = 0
usage.output_tokens_used = 0
usage.usages = 0
usage.save!
end
usage
end
def reset_if_needed!
return if Time.current < reset_at
now = Time.current
update!(
input_tokens_used: 0,
output_tokens_used: 0,
usages: 0,
started_at: now,
reset_at: now + llm_quota.duration_seconds.seconds,
)
end
def increment_usage!(input_tokens:, output_tokens:)
reset_if_needed!
increment!(:usages)
increment!(:input_tokens_used, input_tokens)
increment!(:output_tokens_used, output_tokens)
end
def check_quota!
reset_if_needed!
if quota_exceeded?
raise QuotaExceededError.new(
I18n.t(
"discourse_ai.errors.quota_exceeded",
relative_time: AgeWords.distance_of_time_in_words(reset_at, Time.now),
),
)
end
end
def quota_exceeded?
return false if !llm_quota
(llm_quota.max_tokens.present? && total_tokens_used > llm_quota.max_tokens) ||
(llm_quota.max_usages.present? && usages > llm_quota.max_usages)
end
def total_tokens_used
input_tokens_used + output_tokens_used
end
def remaining_tokens
return nil if llm_quota.max_tokens.nil?
[0, llm_quota.max_tokens - total_tokens_used].max
end
def remaining_usages
return nil if llm_quota.max_usages.nil?
[0, llm_quota.max_usages - usages].max
end
def percentage_tokens_used
return 0 if llm_quota.max_tokens.nil? || llm_quota.max_tokens.zero?
[(total_tokens_used.to_f / llm_quota.max_tokens * 100).round, 100].min
end
def percentage_usages_used
return 0 if llm_quota.max_usages.nil? || llm_quota.max_usages.zero?
[(usages.to_f / llm_quota.max_usages * 100).round, 100].min
end
end
# == Schema Information
#
# Table name: llm_quota_usages
#
# id :bigint not null, primary key
# user_id :bigint not null
# llm_quota_id :bigint not null
# input_tokens_used :integer not null
# output_tokens_used :integer not null
# usages :integer not null
# started_at :datetime not null
# reset_at :datetime not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_llm_quota_usages_on_llm_quota_id (llm_quota_id)
# index_llm_quota_usages_on_user_id_and_llm_quota_id (user_id,llm_quota_id) UNIQUE
#
+8 -1
View File
@@ -7,7 +7,8 @@ class AiSpamSerializer < ApplicationSerializer
:available_llms,
:stats,
:flagging_username,
:spam_score_type
:spam_score_type,
:spam_scanning_user
def is_enabled
object[:enabled]
@@ -47,4 +48,10 @@ class AiSpamSerializer < ApplicationSerializer
def settings
object[:settings]
end
def spam_scanning_user
user = DiscourseAi::AiModeration::SpamScanner.flagging_user
user.serializable_hash(only: %i[id username name admin]) if user.present?
end
end
+1
View File
@@ -20,6 +20,7 @@ class LlmModelSerializer < ApplicationSerializer
:used_by
has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :llm_quotas, serializer: LlmQuotaSerializer, embed: :objects
def used_by
llm_usage =
+9
View File
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class LlmQuotaSerializer < ApplicationSerializer
attributes :id, :group_id, :llm_model_id, :max_tokens, :max_usages, :duration_seconds, :group_name
def group_name
object.group.name
end
end
@@ -21,7 +21,7 @@ export default class AiLlm extends RestModel {
updateProperties() {
const attrs = this.createProperties();
attrs.id = this.id;
attrs.llm_quotas = this.llm_quotas;
return attrs;
}
@@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import DToast from "float-kit/components/d-toast";
import DToastInstance from "float-kit/lib/d-toast-instance";
import AiHelperOptionsList from "../components/ai-helper-options-list";
@@ -45,7 +45,7 @@ export default class AiComposerHelperMenu extends Component {
this.siteSettings.available_locales
);
const locale = availableLocales.find((l) => l.value === siteLocale);
const translatedName = I18n.t(
const translatedName = i18n(
"discourse_ai.ai_helper.context_menu.translate_prompt",
{
language: locale.name,
@@ -90,7 +90,7 @@ export default class AiComposerHelperMenu extends Component {
data: {
theme: "error",
icon: "triangle-exclamation",
message: I18n.t("discourse_ai.ai_helper.no_content_error"),
message: i18n("discourse_ai.ai_helper.no_content_error"),
},
};
@@ -1,5 +1,5 @@
import { computed } from "@ember/object";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
export default ComboBox.extend({
@@ -7,14 +7,14 @@ export default ComboBox.extend({
const content = [
{
id: -1,
name: I18n.t("discourse_ai.ai_persona.tool_strategies.all"),
name: i18n("discourse_ai.ai_persona.tool_strategies.all"),
},
];
[1, 2, 5].forEach((i) => {
content.push({
id: i,
name: I18n.t("discourse_ai.ai_persona.tool_strategies.replies", {
name: i18n("discourse_ai.ai_persona.tool_strategies.replies", {
count: i,
}),
});
@@ -6,18 +6,17 @@ import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { isValidSearchTerm, translateResults } from "discourse/lib/search";
import icon from "discourse-common/helpers/d-icon";
import I18n, { i18n } from "discourse-i18n";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
import AiIndicatorWave from "../../components/ai-indicator-wave";
import AiIndicatorWave from "./ai-indicator-wave";
export default class SemanticSearch extends Component {
static shouldRender(_args, { siteSettings }) {
return siteSettings.ai_embeddings_semantic_search_enabled;
}
const AI_RESULTS_TOGGLED = "full-page-search:ai-results-toggled";
export default class AiFullPageSearch extends Component {
@service appEvents;
@service router;
@service siteSettings;
@@ -26,8 +25,8 @@ export default class SemanticSearch extends Component {
@tracked searching;
@tracked AiResults = [];
@tracked showingAiResults = false;
@tracked sortOrder = this.args.outletArgs.sortOrder;
initialSearchTerm = this.args.outletArgs.search;
@tracked sortOrder = this.args.sortOrder;
initialSearchTerm = this.args.searchTerm;
constructor() {
super(...arguments);
@@ -46,7 +45,7 @@ export default class SemanticSearch extends Component {
return;
}
this.initialSearchTerm = this.args.outletArgs.search;
this.initialSearchTerm = this.args.searchTerm;
this.searching = true;
this.resetAiResults();
return this.performHyDESearch();
@@ -68,26 +67,26 @@ export default class SemanticSearch extends Component {
get searchStateText() {
if (!this.validSearchOrder) {
return I18n.t(
return i18n(
"discourse_ai.embeddings.semantic_search_results.unavailable"
);
}
// Search loading:
if (this.searching) {
return I18n.t("discourse_ai.embeddings.semantic_search_loading");
return i18n("discourse_ai.embeddings.semantic_search_loading");
}
// We have results and we are showing them
if (this.AiResults.length && this.showingAiResults) {
return I18n.t("discourse_ai.embeddings.semantic_search_results.toggle", {
return i18n("discourse_ai.embeddings.semantic_search_results.toggle", {
count: this.AiResults.length,
});
}
// We have results but are hiding them
if (this.AiResults.length && !this.showingAiResults) {
return I18n.t(
return i18n(
"discourse_ai.embeddings.semantic_search_results.toggle_hidden",
{
count: this.AiResults.length,
@@ -100,37 +99,70 @@ export default class SemanticSearch extends Component {
this.AiResults.length === 0 &&
this.searchTerm !== this.initialSearchTerm
) {
return I18n.t("discourse_ai.embeddings.semantic_search_results.new");
return i18n("discourse_ai.embeddings.semantic_search_results.new");
}
// No results:
if (this.AiResults.length === 0) {
return I18n.t("discourse_ai.embeddings.semantic_search_results.none");
return i18n("discourse_ai.embeddings.semantic_search_results.none");
}
}
get settled() {
return (
this.validSearchOrder &&
!this.searching &&
this.searchTerm === this.initialSearchTerm
);
}
get noResults() {
return this.settled && this.AiResults.length === 0;
}
get searchTerm() {
if (this.initialSearchTerm !== this.args.outletArgs.search) {
if (this.initialSearchTerm !== this.args.searchTerm) {
this.initialSearchTerm = undefined;
}
return this.args.outletArgs.search;
return this.args.searchTerm;
}
get searchEnabled() {
return (
this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
this.args.searchType === SEARCH_TYPE_DEFAULT &&
isValidSearchTerm(this.searchTerm, this.siteSettings) &&
this.validSearchOrder
);
}
get searchClass() {
if (!this.validSearchOrder) {
return "unavailable";
} else if (this.searching) {
return "in-progress";
} else if (this.noResults) {
return "no-results";
}
}
get tooltipText() {
return i18n(
`discourse_ai.embeddings.semantic_search_tooltips.${
this.validSearchOrder ? "results_explanation" : "invalid_sort"
}`
);
}
@action
toggleAiResults() {
this.appEvents.trigger(AI_RESULTS_TOGGLED, {
enabled: !this.showingAiResults,
});
if (this.showingAiResults) {
this.args.outletArgs.addSearchResults([], "topic_id");
this.args.addSearchResults([], "topic_id");
} else {
this.args.outletArgs.addSearchResults(this.AiResults, "topic_id");
this.args.addSearchResults(this.AiResults, "topic_id");
}
this.showingAiResults = !this.showingAiResults;
}
@@ -139,7 +171,10 @@ export default class SemanticSearch extends Component {
resetAiResults() {
this.AiResults = [];
this.showingAiResults = false;
this.args.outletArgs.addSearchResults([], "topic_id");
this.args.addSearchResults([], "topic_id");
this.appEvents.trigger(AI_RESULTS_TOGGLED, {
enabled: false,
});
}
performHyDESearch() {
@@ -168,8 +203,8 @@ export default class SemanticSearch extends Component {
@action
sortChanged() {
if (this.sortOrder !== this.args.outletArgs.sortOrder) {
this.sortOrder = this.args.outletArgs.sortOrder;
if (this.sortOrder !== this.args.sortOrder) {
this.sortOrder = this.args.sortOrder;
if (this.validSearchOrder) {
this.onSearch();
@@ -181,13 +216,11 @@ export default class SemanticSearch extends Component {
}
<template>
<span {{didUpdate this.sortChanged @outletArgs.sortOrder}}></span>
<span {{didUpdate this.sortChanged @sortOrder}}></span>
<div class="semantic-search__container search-results" role="region">
<div class="semantic-search__results">
<div
class="semantic-search__searching
{{if this.searching 'in-progress'}}
{{unless this.validSearchOrder 'unavailable'}}"
class={{concatClass "semantic-search__searching" this.searchClass}}
>
<DToggleSwitch
disabled={{this.disableToggleSwitch}}
@@ -205,22 +238,17 @@ export default class SemanticSearch extends Component {
<AiIndicatorWave @loading={{this.searching}} />
{{/if}}
{{#unless this.validSearchOrder}}
<DTooltip
@identifier="semantic-search-unavailable-tooltip"
class="semantic-search__unavailable-tooltip"
>
<:trigger>
{{icon "far-circle-question"}}
</:trigger>
<:content>
{{i18n
"discourse_ai.embeddings.semantic_search_unavailable_tooltip"
}}
</:content>
</DTooltip>
{{/unless}}
<DTooltip
@identifier="semantic-search-tooltip"
class="semantic-search__tooltip"
>
<:trigger>
{{icon "far-circle-question"}}
</:trigger>
<:content>
{{this.tooltipText}}
</:content>
</DTooltip>
</div>
</div>
</div>
@@ -12,11 +12,12 @@ import DButton from "discourse/components/d-button";
import Avatar from "discourse/helpers/bound-avatar-template";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import ComboBox from "select-kit/components/combo-box";
import DTooltip from "float-kit/components/d-tooltip";
import AiLlmQuotaEditor from "./ai-llm-quota-editor";
import AiLlmQuotaModal from "./modal/ai-llm-quota-modal";
export default class AiLlmEditorForm extends Component {
@service toasts;
@@ -29,10 +30,18 @@ export default class AiLlmEditorForm extends Component {
@tracked testResult = null;
@tracked testError = null;
@tracked apiKeySecret = true;
@tracked quotaCount = 0;
@tracked modalIsVisible = false;
constructor() {
super(...arguments);
this.updateQuotaCount();
}
get selectedProviders() {
const t = (provName) => {
return I18n.t(`discourse_ai.llms.providers.${provName}`);
return i18n(`discourse_ai.llms.providers.${provName}`);
};
return this.args.llms.resultSetMeta.providers.map((prov) => {
@@ -45,7 +54,7 @@ export default class AiLlmEditorForm extends Component {
}
get testErrorMessage() {
return I18n.t("discourse_ai.llms.tests.failure", { error: this.testError });
return i18n("discourse_ai.llms.tests.failure", { error: this.testError });
}
get displayTestResult() {
@@ -65,7 +74,7 @@ export default class AiLlmEditorForm extends Component {
}
const localized = usedBy.map((m) => {
return I18n.t(`discourse_ai.llms.usage.${m.type}`, {
return i18n(`discourse_ai.llms.usage.${m.type}`, {
persona: m.name,
});
});
@@ -79,12 +88,30 @@ export default class AiLlmEditorForm extends Component {
}
get inUseWarning() {
return I18n.t("discourse_ai.llms.in_use_warning", {
return i18n("discourse_ai.llms.in_use_warning", {
settings: this.modulesUsingModel,
count: this.args.model.used_by.length,
});
}
get showQuotas() {
return this.quotaCount > 0;
}
get showAddQuotaButton() {
return !this.showQuotas && !this.args.model.isNew;
}
@action
updateQuotaCount() {
this.quotaCount = this.args.model?.llm_quotas?.length || 0;
}
@action
openAddQuotaModal() {
this.modalIsVisible = true;
}
@computed("args.model.provider")
get metaProviderParams() {
return (
@@ -106,7 +133,7 @@ export default class AiLlmEditorForm extends Component {
this.router.transitionTo("adminPlugins.show.discourse-ai-llms.index");
} else {
this.toasts.success({
data: { message: I18n.t("discourse_ai.llms.saved") },
data: { message: i18n("discourse_ai.llms.saved") },
duration: 2000,
});
}
@@ -154,7 +181,7 @@ export default class AiLlmEditorForm extends Component {
@action
delete() {
return this.dialog.confirm({
message: I18n.t("discourse_ai.llms.confirm_delete"),
message: i18n("discourse_ai.llms.confirm_delete"),
didConfirm: () => {
return this.args.model
.destroyRecord()
@@ -169,6 +196,12 @@ export default class AiLlmEditorForm extends Component {
});
}
@action
closeAddQuotaModal() {
this.modalIsVisible = false;
this.updateQuotaCount();
}
<template>
{{#if this.seeded}}
<div class="alert alert-info">
@@ -317,6 +350,17 @@ export default class AiLlmEditorForm extends Component {
</div>
{{/if}}
{{#if this.showQuotas}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.quotas.title"}}</label>
<AiLlmQuotaEditor
@model={{@model}}
@groups={{@groups}}
@didUpdate={{this.updateQuotaCount}}
/>
</div>
{{/if}}
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-llm-editor__test"
@@ -324,7 +368,19 @@ export default class AiLlmEditorForm extends Component {
@disabled={{this.testRunning}}
@label="discourse_ai.llms.tests.title"
/>
{{#if this.showAddQuotaButton}}
<DButton
@action={{this.openAddQuotaModal}}
@label="discourse_ai.llms.quotas.add"
class="btn"
/>
{{#if this.modalIsVisible}}
<AiLlmQuotaModal
@model={{hash llm=@model}}
@closeModal={{this.closeAddQuotaModal}}
/>
{{/if}}
{{/if}}
<DButton
class="btn-primary ai-llm-editor__save"
@action={{this.save}}
@@ -0,0 +1,178 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } 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 { i18n } from "discourse-i18n";
import DurationSelector from "./ai-quota-duration-selector";
import AiLlmQuotaModal from "./modal/ai-llm-quota-modal";
export default class AiLlmQuotaEditor extends Component {
@service store;
@service dialog;
@service site;
@tracked newQuotaGroupIds = null;
@tracked newQuotaTokens = null;
@tracked newQuotaUsages = null;
@tracked newQuotaDuration = 86400; // 1 day default
@tracked modalIsVisible = false;
@action
updateExistingQuotaTokens(quota, event) {
quota.max_tokens = event.target.value;
}
@action
updateExistingQuotaUsages(quota, event) {
quota.max_usages = event.target.value;
}
@action
updateExistingQuotaDuration(quota, value) {
quota.duration_seconds = value;
}
@action
openAddQuotaModal() {
this.modalIsVisible = true;
}
get canAddQuota() {
return (
this.newQuotaGroupId &&
(this.newQuotaTokens || this.newQuotaUsages) &&
this.newQuotaDuration
);
}
@action
updateQuotaTokens(event) {
this.newQuotaTokens = event.target.value;
}
@action
updateQuotaUsages(event) {
this.newQuotaUsages = event.target.value;
}
@action
updateQuotaDuration(event) {
this.newQuotaDuration = event.target.value;
}
@action
updateGroups(groups) {
this.newQuotaGroupIds = groups;
}
@action
async addQuota() {
const quota = {
group_id: this.newQuotaGroupIds[0],
group_name: this.site.groups.findBy("id", this.newQuotaGroupIds[0])?.name,
llm_model_id: this.args.model.id,
max_tokens: this.newQuotaTokens,
max_usages: this.newQuotaUsages,
duration_seconds: this.newQuotaDuration,
};
this.args.model.llm_quotas.pushObject(quota);
if (this.args.didUpdate) {
this.args.didUpdate();
}
}
@action
async deleteQuota(quota) {
this.args.model.llm_quotas.removeObject(quota);
if (this.args.didUpdate) {
this.args.didUpdate();
}
}
@action
closeAddQuotaModal() {
this.modalIsVisible = false;
}
<template>
<div class="ai-llm-quotas">
<table class="ai-llm-quotas__table">
<thead class="ai-llm-quotas__table-head">
<tr class="ai-llm-quotas__header-row">
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.group"
}}</th>
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.max_tokens"
}}</th>
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.max_usages"
}}</th>
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.duration"
}}</th>
<th
class="ai-llm-quotas__header ai-llm-quotas__header--actions"
></th>
</tr>
</thead>
<tbody class="ai-llm-quotas__table-body">
{{#each @model.llm_quotas as |quota|}}
<tr class="ai-llm-quotas__row">
<td class="ai-llm-quotas__cell">{{quota.group_name}}</td>
<td class="ai-llm-quotas__cell">
<input
type="number"
value={{quota.max_tokens}}
class="ai-llm-quotas__input"
min="1"
{{on "input" (fn this.updateExistingQuotaTokens quota)}}
/>
</td>
<td class="ai-llm-quotas__cell">
<input
type="number"
value={{quota.max_usages}}
class="ai-llm-quotas__input"
min="1"
{{on "input" (fn this.updateExistingQuotaUsages quota)}}
/>
</td>
<td class="ai-llm-quotas__cell">
<DurationSelector
@value={{quota.duration_seconds}}
@onChange={{fn this.updateExistingQuotaDuration quota}}
/>
</td>
<td class="ai-llm-quotas__cell ai-llm-quotas__cell--actions">
<DButton
@icon="trash-alt"
class="btn-danger ai-llm-quotas__delete-btn"
@action={{fn this.deleteQuota quota}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
<div class="ai-llm-quotas__actions">
<DButton
@action={{this.openAddQuotaModal}}
@icon="plus"
@label="discourse_ai.llms.quotas.add"
class="btn"
/>
{{#if this.modalIsVisible}}
<AiLlmQuotaModal
@model={{hash llm=@model}}
@closeModal={{this.closeAddQuotaModal}}
/>
{{/if}}
</div>
</div>
</template>
}
@@ -1,6 +1,6 @@
import { computed } from "@ember/object";
import { observes } from "@ember-decorators/object";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import { selectKitOptions } from "select-kit/components/select-kit";
@@ -18,7 +18,7 @@ export default class AiLlmSelector extends ComboBox {
return [
{
id: "blank",
name: I18n.t("discourse_ai.ai_persona.no_llm_selected"),
name: i18n("discourse_ai.ai_persona.no_llm_selected"),
},
].concat(this.llms);
}
@@ -1,16 +1,22 @@
import Component from "@glimmer/component";
import { concat, fn } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import I18n, { i18n } from "discourse-i18n";
import AdminSectionLandingItem from "admin/components/admin-section-landing-item";
import AdminSectionLandingWrapper from "admin/components/admin-section-landing-wrapper";
import DTooltip from "float-kit/components/d-tooltip";
import AiLlmEditor from "./ai-llm-editor";
function isPreseeded(llm) {
if (llm.id < 0) {
return true;
}
}
export default class AiLlmsListEditor extends Component {
@service adminPluginNavManager;
@service router;
@@ -31,11 +37,20 @@ export default class AiLlmsListEditor extends Component {
key = `discourse_ai.llms.model_description.${key}`;
if (I18n.lookup(key, { ignoreMissing: true })) {
return I18n.t(key);
return i18n(key);
}
return "";
}
@action
preseededDescription(llm) {
if (isPreseeded(llm)) {
return i18n("discourse_ai.llms.preseeded_model_description", {
model: llm.name,
});
}
}
sanitizedTranslationKey(id) {
return id.replace(/\./g, "-");
}
@@ -56,7 +71,7 @@ export default class AiLlmsListEditor extends Component {
const options = [
{
id: "none",
name: I18n.t("discourse_ai.llms.preconfigured.fake"),
name: i18n("discourse_ai.llms.preconfigured.fake"),
provider: "fake",
},
];
@@ -98,11 +113,11 @@ export default class AiLlmsListEditor extends Component {
localizeUsage(usage) {
if (usage.type === "ai_persona") {
return I18n.t("discourse_ai.llms.usage.ai_persona", {
return i18n("discourse_ai.llms.usage.ai_persona", {
persona: usage.name,
});
} else {
return I18n.t("discourse_ai.llms.usage." + usage.type);
return i18n("discourse_ai.llms.usage." + usage.type);
}
}
@@ -119,14 +134,17 @@ export default class AiLlmsListEditor extends Component {
@llmTemplate={{@llmTemplate}}
/>
{{else}}
<DPageSubheader
@titleLabel={{i18n "discourse_ai.llms.short_title"}}
@descriptionLabel={{i18n
"discourse_ai.llms.preconfigured.description"
}}
@learnMoreUrl="https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
/>
{{#if this.hasLlmElements}}
<section class="ai-llms-list-editor__configured">
<DPageSubheader
@titleLabel={{i18n "discourse_ai.llms.configured.title"}}
@descriptionLabel={{i18n
"discourse_ai.llms.preconfigured.description"
}}
@learnMoreUrl="https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
/>
<table class="d-admin-table">
<thead>
@@ -143,6 +161,7 @@ export default class AiLlmsListEditor extends Component {
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}}
@@ -150,6 +169,7 @@ export default class AiLlmsListEditor extends Component {
</div>
<div class="ai-llm-list__description">
{{this.modelDescription llm}}
{{this.preseededDescription llm}}
</div>
{{#if llm.used_by}}
<ul class="ai-llm-list-editor__usages">
@@ -168,15 +188,26 @@ export default class AiLlmsListEditor extends Component {
}}
</td>
<td class="d-admin-row__controls">
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.edit"
class="btn btn-default btn-small ai-llm-list__edit-button"
@model={{llm.id}}
>
<div class="d-button-label">
{{i18n "discourse_ai.llms.edit"}}
</div>
</LinkTo>
{{#if (isPreseeded llm)}}
<DTooltip class="ai-llm-list__edit-disabled-tooltip">
<:trigger>
<DButton
class="btn btn-default btn-small disabled"
@label="discourse_ai.llms.edit"
/>
</:trigger>
<:content>
{{i18n "discourse_ai.llms.seeded_warning"}}
</:content>
</DTooltip>
{{else}}
<DButton
class="btn btn-default btn-small ai-llm-list__delete-button"
@label="discourse_ai.llms.edit"
@route="adminPlugins.show.discourse-ai-llms.edit"
@routeModels={{llm.id}}
/>
{{/if}}
</td>
</tr>
{{/each}}
@@ -185,18 +216,7 @@ export default class AiLlmsListEditor extends Component {
</section>
{{/if}}
<section class="ai-llms-list-editor__templates">
<DPageSubheader
@titleLabel={{i18n this.preconfiguredTitle}}
@descriptionLabel={{unless
this.hasLlmElements
(i18n "discourse_ai.llms.preconfigured.description")
}}
@learnMoreUrl={{unless
this.hasLlmElements
"https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
}}
/>
<DPageSubheader @titleLabel={{i18n this.preconfiguredTitle}} />
<AdminSectionLandingWrapper
class="ai-llms-list-editor__templates-list"
>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import Component from "@glimmer/component";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import AiPersonaToolOptionEditor from "./ai-persona-tool-option-editor";
export default class AiPersonaToolOptions extends Component {
@@ -58,7 +58,7 @@ export default class AiPersonaToolOptions extends Component {
<template>
{{#if this.showToolOptions}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.tool_options"}}</label>
<label>{{i18n "discourse_ai.ai_persona.tool_options"}}</label>
<div>
{{#each this.toolOptions as |toolOption|}}
<div class="ai-persona-editor__tool-options">
@@ -15,7 +15,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { sanitize } from "discourse/lib/text";
import { clipboardCopy } from "discourse/lib/utilities";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import eq from "truth-helpers/helpers/eq";
import AiHelperLoading from "../components/ai-helper-loading";
import AiHelperOptionsList from "../components/ai-helper-options-list";
@@ -55,7 +55,7 @@ export default class AiPostHelperMenu extends Component {
const instance = this.tooltip.register(element, {
identifier: "cannot-add-footnote-tooltip",
content: I18n.t(
content: i18n(
"discourse_ai.ai_helper.post_options_menu.footnote_disabled"
),
placement: "top",
@@ -286,7 +286,7 @@ export default class AiPostHelperMenu extends Component {
try {
const result = await ajax(`/posts/${this.args.data.post.id}`);
const sanitizedSuggestion = this._sanitizeForFootnote(this.suggestion);
const credits = I18n.t(
const credits = i18n(
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
);
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
@@ -0,0 +1,111 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
const DURATION_PRESETS = [
{
id: "3600",
seconds: 3600,
nameKey: "discourse_ai.llms.quotas.durations.hour",
},
{
id: "21600",
seconds: 21600,
nameKey: "discourse_ai.llms.quotas.durations.six_hours",
},
{
id: "86400",
seconds: 86400,
nameKey: "discourse_ai.llms.quotas.durations.day",
},
{
id: "604800",
seconds: 604800,
nameKey: "discourse_ai.llms.quotas.durations.week",
},
{ id: "custom", nameKey: "discourse_ai.llms.quotas.durations.custom" },
];
export default class DurationSelector extends Component {
@tracked selectedPresetId = "86400"; // Default to 1 day
@tracked customHours = null;
constructor() {
super(...arguments);
const seconds = this.args.value;
const preset = DURATION_PRESETS.find((p) => p.seconds === seconds);
if (preset) {
this.selectedPresetId = preset.id;
} else {
this.selectedPresetId = "custom";
this.customHours = Math.round(seconds / 3600);
}
}
get presetOptions() {
return DURATION_PRESETS.map((preset) => ({
id: preset.id,
name: i18n(preset.nameKey),
seconds: preset.seconds,
}));
}
get isCustom() {
return this.selectedPresetId === "custom";
}
get currentDurationSeconds() {
if (this.isCustom) {
return this.customHours ? this.customHours * 3600 : 0;
} else {
return parseInt(this.selectedPresetId, 10);
}
}
@action
onPresetChange(value) {
this.selectedPresetId = value;
this.updateValue();
}
@action
onCustomHoursChange(event) {
this.customHours = parseInt(event.target.value, 10);
this.updateValue();
}
updateValue() {
if (this.args.onChange) {
this.args.onChange(this.currentDurationSeconds);
}
}
<template>
<div class="duration-selector">
<ComboBox
@content={{this.presetOptions}}
@value={{this.selectedPresetId}}
@onChange={{this.onPresetChange}}
class="duration-selector__preset"
/>
{{#if this.isCustom}}
<div class="duration-selector__custom">
<input
type="number"
value={{this.customHours}}
class="duration-selector__hours-input"
min="1"
{{on "input" this.onCustomHoursChange}}
/>
<span class="duration-selector__hours-label">
{{i18n "discourse_ai.llms.quotas.hours"}}
</span>
</div>
{{/if}}
</div>
</template>
}
@@ -13,6 +13,7 @@ import DTooltip from "discourse/components/d-tooltip";
import withEventValue from "discourse/helpers/with-event-value";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import getURL from "discourse-common/lib/get-url";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
@@ -35,10 +36,51 @@ export default class AiSpam extends Component {
@tracked isEnabled = false;
@tracked selectedLLM = null;
@tracked customInstructions = "";
@tracked errors = [];
constructor() {
super(...arguments);
this.initializeFromModel();
if (this.args.model?.spam_scanning_user?.admin === false) {
this.errors.push({
message: i18n("discourse_ai.spam.errors.scan_not_admin.message"),
button: {
label: i18n("discourse_ai.spam.errors.scan_not_admin.action"),
action: this.fixScanUserNotAdmin,
},
});
}
}
@action
async fixScanUserNotAdmin() {
const spamScanningUser = this.args.model.spam_scanning_user;
if (!spamScanningUser || spamScanningUser.admin) {
return;
}
try {
const response = await ajax(
`/admin/plugins/discourse-ai/ai-spam/fix-errors`,
{
type: "POST",
data: {
error: "spam_scanner_not_admin",
},
}
);
if (response.success) {
this.toasts.success({
data: { message: i18n("discourse_ai.spam.errors.resolved") },
duration: 2000,
});
}
} catch (error) {
popupAjaxError(error);
} finally {
window.location.reload();
}
}
@action
@@ -165,11 +207,22 @@ export default class AiSpam extends Component {
<template>
<div class="ai-spam">
<section class="ai-spam__settings">
<div class="ai-spam__errors">
{{#each this.errors as |e|}}
<div class="alert alert-error">
{{dIcon "triangle-exclamation"}}
<p>{{e.message}}</p>
<DButton
@action={{e.button.action}}
@translatedLabel={{e.button.label}}
/>
</div>
{{/each}}
</div>
<DPageSubheader
@titleLabel={{i18n "discourse_ai.spam.title"}}
@descriptionLabel={{i18n "discourse_ai.spam.spam_description"}}
/>
<div class="control-group ai-spam__enabled">
<DToggleSwitch
class="ai-spam__toggle"
@@ -12,7 +12,7 @@ import DButton from "discourse/components/d-button";
import DTooltip from "discourse/components/d-tooltip";
import withEventValue from "discourse/helpers/with-event-value";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import AiToolParameterEditor from "./ai-tool-parameter-editor";
import AiToolTestModal from "./modal/ai-tool-test-modal";
@@ -95,7 +95,7 @@ export default class AiToolEditor extends Component {
await this.args.model.save(data);
this.toasts.success({
data: { message: I18n.t("discourse_ai.tools.saved") },
data: { message: i18n("discourse_ai.tools.saved") },
duration: 2000,
});
if (!this.args.tools.any((tool) => tool.id === this.args.model.id)) {
@@ -116,7 +116,7 @@ export default class AiToolEditor extends Component {
@action
delete() {
return this.dialog.confirm({
message: I18n.t("discourse_ai.tools.confirm_delete"),
message: i18n("discourse_ai.tools.confirm_delete"),
didConfirm: async () => {
await this.args.model.destroyRecord();
@@ -148,7 +148,7 @@ export default class AiToolEditor extends Component {
>
{{#if this.showPresets}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.presets"}}</label>
<label>{{i18n "discourse_ai.tools.presets"}}</label>
<ComboBox
@value={{this.presetId}}
@content={{this.presets}}
@@ -165,7 +165,7 @@ export default class AiToolEditor extends Component {
</div>
{{else}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.name"}}</label>
<label>{{i18n "discourse_ai.tools.name"}}</label>
<input
{{on "input" (withEventValue (fn (mut this.editingModel.name)))}}
value={{this.editingModel.name}}
@@ -174,24 +174,24 @@ export default class AiToolEditor extends Component {
/>
<DTooltip
@icon="circle-question"
@content={{I18n.t "discourse_ai.tools.name_help"}}
@content={{i18n "discourse_ai.tools.name_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.description"}}</label>
<label>{{i18n "discourse_ai.tools.description"}}</label>
<textarea
{{on
"input"
(withEventValue (fn (mut this.editingModel.description)))
}}
placeholder={{I18n.t "discourse_ai.tools.description_help"}}
placeholder={{i18n "discourse_ai.tools.description_help"}}
class="ai-tool-editor__description input-xxlarge"
>{{this.editingModel.description}}</textarea>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.summary"}}</label>
<label>{{i18n "discourse_ai.tools.summary"}}</label>
<input
{{on "input" (withEventValue (fn (mut this.editingModel.summary)))}}
value={{this.editingModel.summary}}
@@ -200,17 +200,17 @@ export default class AiToolEditor extends Component {
/>
<DTooltip
@icon="circle-question"
@content={{I18n.t "discourse_ai.tools.summary_help"}}
@content={{i18n "discourse_ai.tools.summary_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.parameters"}}</label>
<label>{{i18n "discourse_ai.tools.parameters"}}</label>
<AiToolParameterEditor @parameters={{this.editingModel.parameters}} />
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.tools.script"}}</label>
<label>{{i18n "discourse_ai.tools.script"}}</label>
<AceEditor
@content={{this.editingModel.script}}
@onChange={{fn (mut this.editingModel.script)}}
@@ -3,8 +3,7 @@ import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageSubheader from "discourse/components/d-page-subheader";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
export default class AiToolListEditor extends Component {
@@ -60,7 +59,7 @@ export default class AiToolListEditor extends Component {
@route="adminPlugins.show.discourse-ai-tools.edit"
@model={{tool}}
class="btn btn-text btn-small"
>{{I18n.t "discourse_ai.tools.edit"}}</LinkTo>
>{{i18n "discourse_ai.tools.edit"}}</LinkTo>
</td>
</tr>
{{/each}}
@@ -5,7 +5,7 @@ import { action } from "@ember/object";
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
const PARAMETER_TYPES = [
@@ -76,7 +76,7 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (withEventValue (fn (mut parameter.name)))}}
value={{parameter.name}}
type="text"
placeholder={{I18n.t "discourse_ai.tools.parameter_name"}}
placeholder={{i18n "discourse_ai.tools.parameter_name"}}
/>
<ComboBox @value={{parameter.type}} @content={{PARAMETER_TYPES}} />
</div>
@@ -86,7 +86,7 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (withEventValue (fn (mut parameter.description)))}}
value={{parameter.description}}
type="text"
placeholder={{I18n.t "discourse_ai.tools.parameter_description"}}
placeholder={{i18n "discourse_ai.tools.parameter_description"}}
/>
</div>
@@ -98,7 +98,7 @@ export default class AiToolParameterEditor extends Component {
type="checkbox"
class="parameter-row__required-toggle"
/>
{{I18n.t "discourse_ai.tools.parameter_required"}}
{{i18n "discourse_ai.tools.parameter_required"}}
</label>
<label>
@@ -108,7 +108,7 @@ export default class AiToolParameterEditor extends Component {
type="checkbox"
class="parameter-row__enum-toggle"
/>
{{I18n.t "discourse_ai.tools.parameter_enum"}}
{{i18n "discourse_ai.tools.parameter_enum"}}
</label>
<DButton
@@ -126,7 +126,7 @@ export default class AiToolParameterEditor extends Component {
{{on "change" (fn this.updateEnumValue parameter enumIndex)}}
value={{enumValue}}
type="text"
placeholder={{I18n.t "discourse_ai.tools.enum_value"}}
placeholder={{i18n "discourse_ai.tools.enum_value"}}
/>
<DButton
@action={{fn this.removeEnumValue parameter enumIndex}}
@@ -0,0 +1,144 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { i18n } from "discourse-i18n";
import GroupChooser from "select-kit/components/group-chooser";
import DTooltip from "float-kit/components/d-tooltip";
import DurationSelector from "../ai-quota-duration-selector";
export default class AiLlmQuotaModal extends Component {
@service site;
@tracked groupIds = null;
@tracked maxTokens = null;
@tracked maxUsages = null;
@tracked duration = 86400; // Default 1 day
get canSave() {
return (
this.groupIds?.length > 0 &&
(this.maxTokens || this.maxUsages) &&
this.duration
);
}
@action
updateGroups(groups) {
this.groupIds = groups;
}
@action
updateDuration(value) {
this.duration = value;
}
@action
updateMaxTokens(event) {
this.maxTokens = event.target.value;
}
@action
updateMaxUsages(event) {
this.maxUsages = event.target.value;
}
@action
save() {
const quota = {
group_id: this.groupIds[0],
group_name: this.site.groups.findBy("id", this.groupIds[0]).name,
llm_model_id: this.args.model.id,
max_tokens: this.maxTokens,
max_usages: this.maxUsages,
duration_seconds: this.duration,
};
this.args.model.llm.llm_quotas.pushObject(quota);
this.args.closeModal();
if (this.args.model.onSave) {
this.args.model.onSave();
}
}
get availableGroups() {
const existingQuotaGroupIds =
this.args.model.llm.llm_quotas.map((q) => q.group_id) || [];
return this.site.groups.filter(
(group) => !existingQuotaGroupIds.includes(group.id) && group.id !== 0
);
}
<template>
<DModal
@title={{i18n "discourse_ai.llms.quotas.add_title"}}
@closeModal={{@closeModal}}
class="ai-llm-quota-modal"
>
<:body>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.quotas.group"}}</label>
<GroupChooser
@value={{this.groupIds}}
@content={{this.availableGroups}}
@onChange={{this.updateGroups}}
@options={{hash maximum=1}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.quotas.max_tokens"}}</label>
<input
type="number"
value={{this.maxTokens}}
class="input-large"
min="1"
{{on "input" this.updateMaxTokens}}
/>
<DTooltip
@icon="circle-question"
@content={{i18n "discourse_ai.llms.quotas.max_tokens_help"}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.quotas.max_usages"}}</label>
<input
type="number"
value={{this.maxUsages}}
class="input-large"
min="1"
{{on "input" this.updateMaxUsages}}
/>
<DTooltip
@icon="circle-question"
@content={{i18n "discourse_ai.llms.quotas.max_usages_help"}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.quotas.duration"}}</label>
<DurationSelector
@value={{this.duration}}
@onChange={{this.updateDuration}}
/>
</div>
</:body>
<:footer>
<DButton
@action={{this.save}}
@label="discourse_ai.llms.quotas.add"
@disabled={{not this.canSave}}
class="btn-primary"
/>
</:footer>
</DModal>
</template>
}
@@ -16,9 +16,8 @@ import htmlClass from "discourse/helpers/html-class";
import { ajax } from "discourse/lib/ajax";
import { shortDateNoYear } from "discourse/lib/formatter";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
@@ -45,11 +44,11 @@ export default class AiSummaryModal extends Component {
streamedTextLength = 0;
get outdatedSummaryWarningText() {
let outdatedText = I18n.t("summary.outdated");
let outdatedText = i18n("summary.outdated");
if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) {
outdatedText += " ";
outdatedText += I18n.t("summary.outdated_posts", {
outdatedText += i18n("summary.outdated_posts", {
count: this.newPostsSinceSummary,
});
}
@@ -7,7 +7,7 @@ import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import { i18n } from "discourse-i18n";
import { jsonToHtml } from "../../lib/utilities";
export default class AiToolTestModal extends Component {
@@ -45,7 +45,7 @@ export default class AiToolTestModal extends Component {
<template>
<DModal
@title={{I18n.t "discourse_ai.tools.test_modal.title"}}
@title={{i18n "discourse_ai.tools.test_modal.title"}}
@closeModal={{@closeModal}}
@bodyClass="ai-tool-test-modal__body"
class="ai-tool-test-modal"
@@ -64,7 +64,7 @@ export default class AiToolTestModal extends Component {
{{#if this.testResult}}
<div class="ai-tool-test-modal__test-result">
<h3>{{I18n.t "discourse_ai.tools.test_modal.result"}}</h3>
<h3>{{i18n "discourse_ai.tools.test_modal.result"}}</h3>
<div>{{this.testResult}}</div>
</div>
{{/if}}

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