diff --git a/.discourse-compatibility b/.discourse-compatibility index cced5d459..de5326293 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 000000000..b5e89c8cc --- /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 b7cc92345..d9fb1e59d 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 @@ class AiLlmsController < ::Admin::AdminController 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 @@ def edit 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 @@ def create 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 @@ def test 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 e5b784351..f3244468b 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 000000000..2612895fe --- /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 000000000..2cbf900db --- /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 48e6e46c4..ea7d5728d 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 000000000..4db1fb7ff --- /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 8545ee6b7..c6567cfe6 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 7f4c2c88e..a18417c47 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 b4e6e40aa..3c60c76f2 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 e1acd4d5e..dec5cb959 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 410c3eac6..4937f3ace 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 2fec3d450..5022f0e24 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 d6dafa9f6..4fd6d8d0c 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 7de41eaeb..a2798cdb7 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 8ce9b091b..eb3a8e11f 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 7df6b32a8..24bf40b2a 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 {