Compare commits

..
Author SHA1 Message Date
awesomerobot 522c1aabb8 wip experiment with /filter route 2024-12-20 15:55:49 -05:00
93 changed files with 501 additions and 2992 deletions
-1
View File
@@ -1,4 +1,3 @@
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
@@ -1,59 +0,0 @@
# 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.includes(:llm_quotas).order(:display_name)
llms = LlmModel.all.order(:display_name)
render json: {
ai_llms:
@@ -40,11 +40,6 @@ 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
@@ -56,25 +51,6 @@ 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
@@ -134,19 +110,6 @@ 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?(
llm_model_id.to_s,
"custom:#{llm_model_id}",
)
return(
render_json_error(
@@ -86,31 +86,6 @@ 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,7 +57,6 @@ 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,7 +4,6 @@ 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 }
@@ -33,7 +32,6 @@ class LlmModel < ActiveRecord::Base
open_ai: {
organization: :text,
disable_native_tools: :checkbox,
disable_streaming: :checkbox,
},
mistral: {
disable_native_tools: :checkbox,
@@ -53,13 +51,11 @@ 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
@@ -1,85 +0,0 @@
# 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
@@ -1,120 +0,0 @@
# 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
#
+1 -8
View File
@@ -7,8 +7,7 @@ class AiSpamSerializer < ApplicationSerializer
:available_llms,
:stats,
:flagging_username,
:spam_score_type,
:spam_scanning_user
:spam_score_type
def is_enabled
object[:enabled]
@@ -48,10 +47,4 @@ 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,7 +20,6 @@ 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
@@ -1,9 +0,0 @@
# 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(
const translatedName = I18n.t(
"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("discourse_ai.ai_helper.no_content_error"),
message: I18n.t("discourse_ai.ai_helper.no_content_error"),
},
};
@@ -0,0 +1,210 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { getOwner } from "discourse-common/lib/get-owner";
const FILTERS = {
category: "category:",
tags: "tags:",
"created by": "created-by:",
"status archived": "status:archived",
"status closed": "status:closed",
"status deleted": "status:deleted",
"status listed": "status:listed",
"status open": "status:open",
"status public": "status:public",
"status unlisted": "status:unlisted",
"in bookmarked": "in:bookmarked",
"in muted": "in:muted",
"in normal": "in:normal",
"in pinned": "in:pinned",
"in tracking": "in:tracking",
"in watching": "in:watching",
"activity before": "activity-before:",
"activity after": "activity-after:",
"created before": "created-before:",
"created after": "created-after:",
"latest post before": "latest-post-before:",
"latest post after": "latest-post-after:",
"minimum likes": "likes-min:",
"maximum likes": "likes-max:",
"minimum likes by original poster": "likes-op-min:",
"maximum likes by original poster": "likes-op-max:",
"minimum posts": "posts-min:",
"maximum posts": "posts-max:",
"minimum posters": "posters-min:",
"maximum posters": "posters-max:",
"minimum views": "views-min:",
"maximum views": "views-max:",
"sort by activity": "order:activity",
"sort by category": "order:category",
"sort by created date": "order:created",
"sort by latest post": "order:latest-post",
"sort by likes": "order:likes",
"sort by likes (original poster)": "order:likes-op",
"sort by posters": "order:posters",
"sort by views": "order:views",
};
function constructQueryString(aiFilters) {
return aiFilters
.flatMap((filterLine) => {
// Split on spaces to get individual "key:value" segments
return filterLine.split(" ").filter((segment) => segment.includes(":"));
})
.map((filterSegment) => {
const [key, ...valueParts] = filterSegment.split(":");
const value = valueParts.join(":").trim();
const validKey = Object.keys(FILTERS).find(
(filterKey) => filterKey.toLowerCase() === key.toLowerCase()
);
if (validKey) {
return `${FILTERS[validKey]}${value}`;
}
// If the segment already starts with a known prefix
if (
Object.values(FILTERS).some((filterValue) =>
filterSegment.startsWith(filterValue)
)
) {
return filterSegment.trim();
}
// Otherwise, it's invalid
return null;
})
.filter(Boolean)
.join(" ");
}
export default class AiFilterInput extends Component {
@service site;
@tracked userInput = "";
@tracked loading = false;
@tracked error = null;
siteCategories = [
"gaming",
"support",
"general",
"announcements",
"off-topic",
];
get discoveryFilter() {
return getOwner(this).lookup("controller:discovery/filter");
}
@action
handleInputChange(event) {
this.userInput = event.target.value;
}
@action
handleKeydown(event) {
if (event.key === "Enter") {
this.submitQuery();
}
}
@action
async submitQuery() {
if (!this.userInput.trim()) {
this.error = "Please enter a query.";
return;
}
this.loading = true;
this.error = null;
try {
const categoriesListStr = this.site.categories
.map((cat) => cat.name)
.join(", ");
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = String(now.getMonth() + 1).padStart(2, "0");
const currentDay = String(now.getDate()).padStart(2, "0");
const currentDateStr = `${currentYear}-${currentMonth}-${currentDay}`;
const response = await ajax("/discourse-ai/ai-helper/suggest", {
method: "POST",
data: {
text: this.userInput,
mode: -305,
custom_prompt: `
The following are valid categories on this site: ${categoriesListStr}.
The current date (in the user's time) is: ${currentDateStr}.
If the user references dates, convert them to YYYY-MM-DD.
If the user says "0 replies," that means posts-max:1.
Map ambiguous categories to the closest match.
Return filters as a clean, space-separated string without quotes.
Do not add extra quotes around filters.
`,
},
});
let aiSuggestions = response.suggestions || "";
aiSuggestions = aiSuggestions.replace(/"/g, ""); // remove all quotes
// turn it into an array with one element
const aiFilters = aiSuggestions ? [aiSuggestions] : [];
let queryString = constructQueryString(aiFilters);
// remove quotes
queryString = queryString.replace(/"/g, "");
this.args.updateQueryString(queryString);
this.discoveryFilter.updateTopicsListQueryParams(queryString);
this.aiTitle = aiTitle;
this.aiExplanation = aiExplanation;
document.getElementById("queryStringInput").focus();
} catch (error) {
popupAjaxError(error);
this.error = "Failed to process the query. Please try again.";
} finally {
this.loading = false;
}
}
<template>
<div class="ai-filter-input">
{{#if this.aiTitle}}
<h2>{{this.aiTitle}}</h2>
{{/if}}
{{#if this.aiExplanation}}
<p class="explanation">{{this.aiExplanation}}</p>
{{/if}}
<input
type="text"
id="aiQueryStringInput"
placeholder="Enter your query..."
value={{this.userInput}}
{{on "input" this.handleInputChange}}
{{on "keydown" this.handleKeydown}}
/>
{{if this.loading "Processing..."}}
{{#if this.error}}
<p class="error">{{this.error}}</p>
{{/if}}
</div>
</template>
}
@@ -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("discourse_ai.ai_persona.tool_strategies.all"),
name: I18n.t("discourse_ai.ai_persona.tool_strategies.all"),
},
];
[1, 2, 5].forEach((i) => {
content.push({
id: i,
name: i18n("discourse_ai.ai_persona.tool_strategies.replies", {
name: I18n.t("discourse_ai.ai_persona.tool_strategies.replies", {
count: i,
}),
});
@@ -12,12 +12,11 @@ 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-i18n";
import i18n from "discourse-common/helpers/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;
@@ -30,18 +29,10 @@ 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(`discourse_ai.llms.providers.${provName}`);
return I18n.t(`discourse_ai.llms.providers.${provName}`);
};
return this.args.llms.resultSetMeta.providers.map((prov) => {
@@ -54,7 +45,7 @@ export default class AiLlmEditorForm extends Component {
}
get testErrorMessage() {
return i18n("discourse_ai.llms.tests.failure", { error: this.testError });
return I18n.t("discourse_ai.llms.tests.failure", { error: this.testError });
}
get displayTestResult() {
@@ -74,7 +65,7 @@ export default class AiLlmEditorForm extends Component {
}
const localized = usedBy.map((m) => {
return i18n(`discourse_ai.llms.usage.${m.type}`, {
return I18n.t(`discourse_ai.llms.usage.${m.type}`, {
persona: m.name,
});
});
@@ -88,30 +79,12 @@ export default class AiLlmEditorForm extends Component {
}
get inUseWarning() {
return i18n("discourse_ai.llms.in_use_warning", {
return I18n.t("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 (
@@ -133,7 +106,7 @@ export default class AiLlmEditorForm extends Component {
this.router.transitionTo("adminPlugins.show.discourse-ai-llms.index");
} else {
this.toasts.success({
data: { message: i18n("discourse_ai.llms.saved") },
data: { message: I18n.t("discourse_ai.llms.saved") },
duration: 2000,
});
}
@@ -181,7 +154,7 @@ export default class AiLlmEditorForm extends Component {
@action
delete() {
return this.dialog.confirm({
message: i18n("discourse_ai.llms.confirm_delete"),
message: I18n.t("discourse_ai.llms.confirm_delete"),
didConfirm: () => {
return this.args.model
.destroyRecord()
@@ -196,12 +169,6 @@ export default class AiLlmEditorForm extends Component {
});
}
@action
closeAddQuotaModal() {
this.modalIsVisible = false;
this.updateQuotaCount();
}
<template>
{{#if this.seeded}}
<div class="alert alert-info">
@@ -350,17 +317,6 @@ 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"
@@ -368,19 +324,7 @@ 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}}
@@ -1,178 +0,0 @@
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("discourse_ai.ai_persona.no_llm_selected"),
name: I18n.t("discourse_ai.ai_persona.no_llm_selected"),
},
].concat(this.llms);
}
@@ -1,22 +1,16 @@
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, { i18n } from "discourse-i18n";
import i18n from "discourse-common/helpers/i18n";
import 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;
@@ -37,20 +31,11 @@ export default class AiLlmsListEditor extends Component {
key = `discourse_ai.llms.model_description.${key}`;
if (I18n.lookup(key, { ignoreMissing: true })) {
return i18n(key);
return I18n.t(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, "-");
}
@@ -71,7 +56,7 @@ export default class AiLlmsListEditor extends Component {
const options = [
{
id: "none",
name: i18n("discourse_ai.llms.preconfigured.fake"),
name: I18n.t("discourse_ai.llms.preconfigured.fake"),
provider: "fake",
},
];
@@ -113,11 +98,11 @@ export default class AiLlmsListEditor extends Component {
localizeUsage(usage) {
if (usage.type === "ai_persona") {
return i18n("discourse_ai.llms.usage.ai_persona", {
return I18n.t("discourse_ai.llms.usage.ai_persona", {
persona: usage.name,
});
} else {
return i18n("discourse_ai.llms.usage." + usage.type);
return I18n.t("discourse_ai.llms.usage." + usage.type);
}
}
@@ -134,17 +119,14 @@ 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>
@@ -161,7 +143,6 @@ 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}}
@@ -169,7 +150,6 @@ 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">
@@ -188,26 +168,15 @@ export default class AiLlmsListEditor extends Component {
}}
</td>
<td class="d-admin-row__controls">
{{#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}}
<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>
</td>
</tr>
{{/each}}
@@ -216,7 +185,18 @@ export default class AiLlmsListEditor extends Component {
</section>
{{/if}}
<section class="ai-llms-list-editor__templates">
<DPageSubheader @titleLabel={{i18n this.preconfiguredTitle}} />
<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"
}}
/>
<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 "discourse_ai.ai_persona.tool_options"}}</label>
<label>{{I18n.t "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(
content: I18n.t(
"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(
const credits = I18n.t(
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
);
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
@@ -1,111 +0,0 @@
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,7 +13,6 @@ 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";
@@ -36,51 +35,10 @@ 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
@@ -207,22 +165,11 @@ 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("discourse_ai.tools.saved") },
data: { message: I18n.t("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("discourse_ai.tools.confirm_delete"),
message: I18n.t("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 "discourse_ai.tools.presets"}}</label>
<label>{{I18n.t "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 "discourse_ai.tools.name"}}</label>
<label>{{I18n.t "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 "discourse_ai.tools.name_help"}}
@content={{I18n.t "discourse_ai.tools.name_help"}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.tools.description"}}</label>
<label>{{I18n.t "discourse_ai.tools.description"}}</label>
<textarea
{{on
"input"
(withEventValue (fn (mut this.editingModel.description)))
}}
placeholder={{i18n "discourse_ai.tools.description_help"}}
placeholder={{I18n.t "discourse_ai.tools.description_help"}}
class="ai-tool-editor__description input-xxlarge"
>{{this.editingModel.description}}</textarea>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.tools.summary"}}</label>
<label>{{I18n.t "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 "discourse_ai.tools.summary_help"}}
@content={{I18n.t "discourse_ai.tools.summary_help"}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.tools.parameters"}}</label>
<label>{{I18n.t "discourse_ai.tools.parameters"}}</label>
<AiToolParameterEditor @parameters={{this.editingModel.parameters}} />
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.tools.script"}}</label>
<label>{{I18n.t "discourse_ai.tools.script"}}</label>
<AceEditor
@content={{this.editingModel.script}}
@onChange={{fn (mut this.editingModel.script)}}
@@ -3,7 +3,8 @@ 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-i18n";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
export default class AiToolListEditor extends Component {
@@ -59,7 +60,7 @@ export default class AiToolListEditor extends Component {
@route="adminPlugins.show.discourse-ai-tools.edit"
@model={{tool}}
class="btn btn-text btn-small"
>{{i18n "discourse_ai.tools.edit"}}</LinkTo>
>{{I18n.t "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 "discourse_ai.tools.parameter_name"}}
placeholder={{I18n.t "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 "discourse_ai.tools.parameter_description"}}
placeholder={{I18n.t "discourse_ai.tools.parameter_description"}}
/>
</div>
@@ -98,7 +98,7 @@ export default class AiToolParameterEditor extends Component {
type="checkbox"
class="parameter-row__required-toggle"
/>
{{i18n "discourse_ai.tools.parameter_required"}}
{{I18n.t "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 "discourse_ai.tools.parameter_enum"}}
{{I18n.t "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 "discourse_ai.tools.enum_value"}}
placeholder={{I18n.t "discourse_ai.tools.enum_value"}}
/>
<DButton
@action={{fn this.removeEnumValue parameter enumIndex}}
@@ -1,144 +0,0 @@
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,8 +16,9 @@ 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";
@@ -44,11 +45,11 @@ export default class AiSummaryModal extends Component {
streamedTextLength = 0;
get outdatedSummaryWarningText() {
let outdatedText = i18n("summary.outdated");
let outdatedText = I18n.t("summary.outdated");
if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) {
outdatedText += " ";
outdatedText += i18n("summary.outdated_posts", {
outdatedText += I18n.t("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 "discourse_ai.tools.test_modal.title"}}
@title={{I18n.t "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 "discourse_ai.tools.test_modal.result"}}</h3>
<h3>{{I18n.t "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