From d07cf51653b28edff9efa78e09c693c4ae46ba65 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 14 Jan 2025 15:54:09 +1100 Subject: [PATCH] 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 --- .discourse-compatibility | 1 + .../admin/ai_llm_quotas_controller.rb | 59 +++++ .../discourse_ai/admin/ai_llms_controller.rb | 39 ++- app/models/llm_model.rb | 1 + app/models/llm_quota.rb | 85 ++++++ app/models/llm_quota_usage.rb | 120 +++++++++ app/serializers/llm_model_serializer.rb | 1 + app/serializers/llm_quota_serializer.rb | 9 + .../discourse/admin/models/ai-llm.js | 2 +- .../components/ai-composer-helper-menu.gjs | 6 +- .../ai-forced-tool-strategy-selector.gjs | 6 +- .../components/ai-llm-editor-form.gjs | 74 +++++- .../components/ai-llm-quota-editor.gjs | 178 +++++++++++++ .../discourse/components/ai-llm-selector.js | 4 +- .../components/ai-llms-list-editor.gjs | 11 +- .../components/ai-persona-editor.gjs | 86 +++--- .../components/ai-persona-tool-options.gjs | 4 +- .../components/ai-post-helper-menu.gjs | 6 +- .../components/ai-quota-duration-selector.gjs | 111 ++++++++ .../discourse/components/ai-tool-editor.gjs | 24 +- .../components/ai-tool-list-editor.gjs | 5 +- .../components/ai-tool-parameter-editor.gjs | 12 +- .../components/modal/ai-llm-quota-modal.gjs | 144 ++++++++++ .../components/modal/ai-summary-modal.gjs | 7 +- .../components/modal/ai-tool-test-modal.gjs | 6 +- .../modal/chat-modal-channel-summary.gjs | 5 +- .../components/modal/debug-ai-modal.gjs | 5 +- .../modal/share-full-topic-modal.gjs | 5 +- .../components/modal/share-modal.gjs | 5 +- .../components/modal/spam-test-modal.gjs | 18 +- .../discourse/components/rag-options.gjs | 14 +- .../components/rag-upload-progress.gjs | 8 +- .../discourse/components/rag-uploader.gjs | 12 +- .../after-d-editor/composer-open.js | 4 +- .../discourse/lib/ai-bot-helper.js | 4 +- .../discourse/lib/copy-conversation.js | 4 +- .../initializers/ai-image-caption.js | 6 +- .../modules/llms/common/ai-llm-quotas.scss | 71 +++++ config/locales/client.en.yml | 18 ++ config/locales/server.en.yml | 2 + config/routes.rb | 5 + .../20250102035341_add_llm_quota_tables.rb | 31 +++ lib/completions/endpoints/base.rb | 5 +- plugin.rb | 1 + spec/fabricators/llm_quota_fabricator.rb | 9 + .../fabricators/llm_quota_usage_fabricator.rb | 11 + spec/models/llm_quota_spec.rb | 129 +++++++++ spec/models/llm_quota_usage_spec.rb | 250 ++++++++++++++++++ .../admin/ai_llm_quotas_controller_spec.rb | 108 ++++++++ .../requests/admin/ai_llms_controller_spec.rb | 104 ++++++++ 50 files changed, 1684 insertions(+), 151 deletions(-) create mode 100644 app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb create mode 100644 app/models/llm_quota.rb create mode 100644 app/models/llm_quota_usage.rb create mode 100644 app/serializers/llm_quota_serializer.rb create mode 100644 assets/javascripts/discourse/components/ai-llm-quota-editor.gjs create mode 100644 assets/javascripts/discourse/components/ai-quota-duration-selector.gjs create mode 100644 assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs create mode 100644 assets/stylesheets/modules/llms/common/ai-llm-quotas.scss create mode 100644 db/migrate/20250102035341_add_llm_quota_tables.rb create mode 100644 spec/fabricators/llm_quota_fabricator.rb create mode 100644 spec/fabricators/llm_quota_usage_fabricator.rb create mode 100644 spec/models/llm_quota_spec.rb create mode 100644 spec/models/llm_quota_usage_spec.rb create mode 100644 spec/requests/admin/ai_llm_quotas_controller_spec.rb diff --git a/.discourse-compatibility b/.discourse-compatibility index cced5d45..de532629 100644 --- a/.discourse-compatibility +++ b/.discourse-compatibility @@ -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 diff --git a/app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb b/app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb new file mode 100644 index 00000000..b5e89c8c --- /dev/null +++ b/app/controllers/discourse_ai/admin/ai_llm_quotas_controller.rb @@ -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 diff --git a/app/controllers/discourse_ai/admin/ai_llms_controller.rb b/app/controllers/discourse_ai/admin/ai_llms_controller.rb index b7cc9234..d9fb1e59 100644 --- a/app/controllers/discourse_ai/admin/ai_llms_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_llms_controller.rb @@ -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? diff --git a/app/models/llm_model.rb b/app/models/llm_model.rb index e5b78435..f3244468 100644 --- a/app/models/llm_model.rb +++ b/app/models/llm_model.rb @@ -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 } diff --git a/app/models/llm_quota.rb b/app/models/llm_quota.rb new file mode 100644 index 00000000..2612895f --- /dev/null +++ b/app/models/llm_quota.rb @@ -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) +# diff --git a/app/models/llm_quota_usage.rb b/app/models/llm_quota_usage.rb new file mode 100644 index 00000000..2cbf900d --- /dev/null +++ b/app/models/llm_quota_usage.rb @@ -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 +# diff --git a/app/serializers/llm_model_serializer.rb b/app/serializers/llm_model_serializer.rb index 48e6e46c..ea7d5728 100644 --- a/app/serializers/llm_model_serializer.rb +++ b/app/serializers/llm_model_serializer.rb @@ -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 = diff --git a/app/serializers/llm_quota_serializer.rb b/app/serializers/llm_quota_serializer.rb new file mode 100644 index 00000000..4db1fb7f --- /dev/null +++ b/app/serializers/llm_quota_serializer.rb @@ -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 diff --git a/assets/javascripts/discourse/admin/models/ai-llm.js b/assets/javascripts/discourse/admin/models/ai-llm.js index 8545ee6b..c6567cfe 100644 --- a/assets/javascripts/discourse/admin/models/ai-llm.js +++ b/assets/javascripts/discourse/admin/models/ai-llm.js @@ -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; } diff --git a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs index 7f4c2c88..a18417c4 100644 --- a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs +++ b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs @@ -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"), }, }; diff --git a/assets/javascripts/discourse/components/ai-forced-tool-strategy-selector.gjs b/assets/javascripts/discourse/components/ai-forced-tool-strategy-selector.gjs index b4e6e40a..3c60c76f 100644 --- a/assets/javascripts/discourse/components/ai-forced-tool-strategy-selector.gjs +++ b/assets/javascripts/discourse/components/ai-forced-tool-strategy-selector.gjs @@ -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, }), }); diff --git a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs index e1acd4d5..dec5cb95 100644 --- a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs +++ b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs @@ -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(); + } + +} diff --git a/assets/javascripts/discourse/components/ai-llm-selector.js b/assets/javascripts/discourse/components/ai-llm-selector.js index 410c3eac..4937f3ac 100644 --- a/assets/javascripts/discourse/components/ai-llm-selector.js +++ b/assets/javascripts/discourse/components/ai-llm-selector.js @@ -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); } diff --git a/assets/javascripts/discourse/components/ai-llms-list-editor.gjs b/assets/javascripts/discourse/components/ai-llms-list-editor.gjs index 2fec3d45..5022f0e2 100644 --- a/assets/javascripts/discourse/components/ai-llms-list-editor.gjs +++ b/assets/javascripts/discourse/components/ai-llms-list-editor.gjs @@ -5,8 +5,7 @@ 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"; @@ -38,7 +37,7 @@ 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 ""; } @@ -72,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", }, ]; @@ -114,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); } } diff --git a/assets/javascripts/discourse/components/ai-persona-editor.gjs b/assets/javascripts/discourse/components/ai-persona-editor.gjs index d6dafa9f..4fd6d8d0 100644 --- a/assets/javascripts/discourse/components/ai-persona-editor.gjs +++ b/assets/javascripts/discourse/components/ai-persona-editor.gjs @@ -15,7 +15,7 @@ import DToggleSwitch from "discourse/components/d-toggle-switch"; import Avatar from "discourse/helpers/bound-avatar-template"; import { popupAjaxError } from "discourse/lib/ajax-error"; import Group from "discourse/models/group"; -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 GroupChooser from "select-kit/components/group-chooser"; @@ -108,7 +108,7 @@ export default class PersonaEditor extends Component { @cached get maxPixelValues() { const l = (key) => - I18n.t(`discourse_ai.ai_persona.vision_max_pixel_sizes.${key}`); + i18n(`discourse_ai.ai_persona.vision_max_pixel_sizes.${key}`); return [ { id: "low", name: l("low"), pixels: 65536 }, { id: "medium", name: l("medium"), pixels: 262144 }, @@ -140,7 +140,7 @@ export default class PersonaEditor extends Component { ); } else { this.toasts.success({ - data: { message: I18n.t("discourse_ai.ai_persona.saved") }, + data: { message: i18n("discourse_ai.ai_persona.saved") }, duration: 2000, }); } @@ -205,7 +205,7 @@ export default class PersonaEditor extends Component { @action delete() { return this.dialog.confirm({ - message: I18n.t("discourse_ai.ai_persona.confirm_delete"), + message: i18n("discourse_ai.ai_persona.confirm_delete"), didConfirm: () => { return this.args.model.destroyRecord().then(() => { this.args.personas.removeObject(this.args.model); @@ -316,11 +316,11 @@ export default class PersonaEditor extends Component { />
- +
- +
- +
- +
- + {{I18n.t "discourse_ai.tools.edit"}} + >{{i18n "discourse_ai.tools.edit"}} {{/each}} diff --git a/assets/javascripts/discourse/components/ai-tool-parameter-editor.gjs b/assets/javascripts/discourse/components/ai-tool-parameter-editor.gjs index 7de41eae..a2798cdb 100644 --- a/assets/javascripts/discourse/components/ai-tool-parameter-editor.gjs +++ b/assets/javascripts/discourse/components/ai-tool-parameter-editor.gjs @@ -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"}} />
@@ -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"}} /> @@ -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"}} 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 + ); + } + + +} diff --git a/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs b/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs index 8ce9b091..eb3a8e11 100644 --- a/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs +++ b/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs @@ -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, }); } diff --git a/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs b/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs index 7df6b32a..24bf40b2 100644 --- a/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs +++ b/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs @@ -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 {