DEV: converts llm admin page to use form kit (#1099)

This also converts the quota editor, and the quota modal.
This commit is contained in:
Joffrey JAFFEUX 2025-02-04 11:51:01 +01:00 committed by GitHub
parent 43c56d7c92
commit 40e996b174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 573 additions and 637 deletions

View File

@ -25,9 +25,9 @@ export default class AiLlm extends RestModel {
return attrs;
}
async testConfig() {
return await ajax(`/admin/plugins/discourse-ai/ai-llms/test.json`, {
data: { ai_llm: this.createProperties() },
async testConfig(llmConfig) {
return await ajax("/admin/plugins/discourse-ai/ai-llms/test.json", {
data: { ai_llm: llmConfig ?? this.createProperties() },
});
}
}

View File

@ -1,42 +1,76 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { concat, get, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { cached, tracked } from "@glimmer/tracking";
import { concat, fn, get } from "@ember/helper";
import { action, computed } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import { eq, gt } from "truth-helpers";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import Form from "discourse/components/form";
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 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 DurationSelector from "./ai-quota-duration-selector";
import AiLlmQuotaModal from "./modal/ai-llm-quota-modal";
export default class AiLlmEditorForm extends Component {
@service toasts;
@service router;
@service dialog;
@service modal;
@tracked isSaving = false;
@tracked testRunning = false;
@tracked testResult = null;
@tracked testError = null;
@tracked apiKeySecret = true;
@tracked quotaCount = 0;
@tracked modalIsVisible = false;
@cached
get formData() {
if (this.args.llmTemplate) {
let [id, modelName] = this.args.llmTemplate.split(/-(.*)/);
if (id === "none") {
return { provider_params: {} };
}
constructor() {
super(...arguments);
this.updateQuotaCount();
const info = this.args.llms.resultSetMeta.presets.findBy("id", id);
const modelInfo = info.models.findBy("name", modelName);
const params =
this.args.llms.resultSetMeta.provider_params[info.provider] ?? {};
return {
max_prompt_tokens: modelInfo.tokens,
tokenizer: info.tokenizer,
url: modelInfo.endpoint || info.endpoint,
display_name: modelInfo.display_name,
name: modelInfo.name,
provider: info.provider,
provider_params: Object.fromEntries(
Object.entries(params).map(([k, v]) => [
k,
v?.type === "enum" ? v.default : null,
])
),
};
}
const { model } = this.args;
return {
max_prompt_tokens: model.max_prompt_tokens,
api_key: model.api_key,
tokenizer: model.tokenizer,
url: model.url,
display_name: model.display_name,
name: model.name,
provider: model.provider,
enabled_chat_bot: model.enabled_chat_bot,
vision_enabled: model.vision_enabled,
provider_params: model.provider_params,
llm_quotas: model.llm_quotas,
};
}
get selectedProviders() {
@ -44,9 +78,17 @@ export default class AiLlmEditorForm extends Component {
return i18n(`discourse_ai.llms.providers.${provName}`);
};
return this.args.llms.resultSetMeta.providers.map((prov) => {
return { id: prov, name: t(prov) };
});
return this.args.llms.resultSetMeta.providers
.map((prov) => {
return { id: prov, name: t(prov) };
})
.sort((a, b) => a.name.localeCompare(b.name));
}
get tokenizers() {
return this.args.llms.resultSetMeta.tokenizers.sort((a, b) =>
a.name.localeCompare(b.name)
);
}
get adminUser() {
@ -94,55 +136,49 @@ export default class AiLlmEditorForm extends Component {
});
}
get showQuotas() {
return this.quotaCount > 0;
}
get showAddQuotaButton() {
return !this.showQuotas && !this.args.model.isNew;
return !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() {
const params =
this.args.llms.resultSetMeta.provider_params[this.args.model.provider] ||
{};
return Object.entries(params).map(([field, value]) => {
if (typeof value === "string") {
return { field, type: value };
} else if (typeof value === "object") {
if (value.values) {
value = { ...value };
value.values = value.values.map((v) => {
return { id: v, name: v };
});
}
this.args.model.provider_params[field] =
this.args.model.provider_params[field] || value.default;
return { field, ...value };
}
return { field, type: "text" }; // fallback
openAddQuotaModal(addItemToCollection) {
this.modal.show(AiLlmQuotaModal, {
model: { llm: this.args.model, addItemToCollection },
});
}
@action
async save() {
metaProviderParams(provider) {
const params = this.args.llms.resultSetMeta.provider_params[provider] || {};
return Object.entries(params).reduce((acc, [field, value]) => {
if (typeof value === "string") {
acc[field] = { type: value };
} else if (typeof value === "object") {
if (value.values) {
value = { ...value };
value.values = value.values.map((v) => ({ id: v, name: v }));
}
acc[field] = {
type: value.type || "text",
values: value.values || [],
default: value.default ?? undefined,
};
} else {
acc[field] = { type: "text" }; // fallback
}
return acc;
}, {});
}
@action
async save(data) {
this.isSaving = true;
const isNew = this.args.model.isNew;
try {
await this.args.model.save();
await this.args.model.save(data);
if (isNew) {
this.args.llms.addObject(this.args.model);
@ -163,11 +199,11 @@ export default class AiLlmEditorForm extends Component {
}
@action
async test() {
async test(data) {
this.testRunning = true;
try {
const configTestResult = await this.args.model.testConfig();
const configTestResult = await this.args.model.testConfig(data);
this.testResult = configTestResult.success;
if (this.testResult) {
@ -184,16 +220,6 @@ export default class AiLlmEditorForm extends Component {
}
}
@action
makeApiKeySecret() {
this.apiKeySecret = true;
}
@action
toggleApiKeySecret() {
this.apiKeySecret = !this.apiKeySecret;
}
@action
delete() {
return this.dialog.confirm({
@ -212,154 +238,167 @@ export default class AiLlmEditorForm extends Component {
});
}
@action
closeAddQuotaModal() {
this.modalIsVisible = false;
this.updateQuotaCount();
}
<template>
{{#if this.seeded}}
<div class="alert alert-info">
{{icon "circle-exclamation"}}
{{i18n "discourse_ai.llms.seeded_warning"}}
</div>
{{/if}}
{{#if this.modulesUsingModel}}
<div class="alert alert-info">
{{icon "circle-exclamation"}}
{{this.inUseWarning}}
</div>
{{/if}}
<form class="form-horizontal ai-llm-editor {{if this.seeded 'seeded'}}">
<div class="control-group">
<label>{{i18n "discourse_ai.llms.display_name"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__display-name"
@type="text"
@value={{@model.display_name}}
disabled={{this.seeded}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.name"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__name"
@type="text"
@value={{@model.name}}
disabled={{this.seeded}}
/>
<DTooltip
@icon="circle-question"
@content={{i18n "discourse_ai.llms.hints.name"}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.provider"}}</label>
<ComboBox
@value={{@model.provider}}
@content={{this.selectedProviders}}
@class="ai-llm-editor__provider"
@options={{hash disabled=this.seeded}}
/>
</div>
<Form
@onSubmit={{this.save}}
@data={{this.formData}}
class="ai-llm-editor {{if this.seeded 'seeded'}}"
as |form data|
>
{{#if this.seeded}}
<form.Alert @icon="circle-info">
{{i18n "discourse_ai.llms.seeded_warning"}}
</form.Alert>
{{/if}}
{{#if this.modulesUsingModel}}
<form.Alert @icon="circle-info">
{{this.inUseWarning}}
</form.Alert>
{{/if}}
<form.Field
@name="display_name"
@title={{i18n "discourse_ai.llms.display_name"}}
@validation="required|length:1,100"
@disabled={{this.seeded}}
@format="large"
@tooltip={{i18n "discourse_ai.llms.hints.max_prompt_tokens"}}
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="name"
@title={{i18n "discourse_ai.llms.name"}}
@tooltip={{i18n "discourse_ai.llms.hints.name"}}
@validation="required"
@disabled={{this.seeded}}
@format="large"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="provider"
@title={{i18n "discourse_ai.llms.provider"}}
@disabled={{this.seeded}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.selectedProviders as |provider|}}
<select.Option
@value={{provider.id}}
>{{provider.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{#unless this.seeded}}
{{#if this.canEditURL}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.url"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
required="true"
/>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.api_key"}}</label>
<div class="ai-llm-editor__secret-api-key-group">
<Input
@value={{@model.api_key}}
class="ai-llm-editor-input ai-llm-editor__api-key"
@type={{if this.apiKeySecret "password" "text"}}
required="true"
{{on "focusout" this.makeApiKeySecret}}
/>
<DButton
@action={{this.toggleApiKeySecret}}
@icon="far-eye-slash"
/>
</div>
</div>
{{#each this.metaProviderParams as |param|}}
<div
class="control-group ai-llm-editor-provider-param__{{param.type}}"
<form.Field
@name="url"
@title={{i18n "discourse_ai.llms.url"}}
@validation="required"
@format="large"
as |field|
>
<label>{{i18n
(concat "discourse_ai.llms.provider_fields." param.field)
}}</label>
{{#if (eq param.type "enum")}}
<ComboBox
@value={{mut (get @model.provider_params param.field)}}
@content={{param.values}}
/>
{{else if (eq param.type "checkbox")}}
<Input
@type={{param.type}}
@checked={{mut (get @model.provider_params param.field)}}
/>
{{else}}
<Input
@type={{param.type}}
@value={{mut (get @model.provider_params param.field)}}
/>
{{/if}}
</div>
{{/each}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.tokenizer"}}</label>
<ComboBox
@value={{@model.tokenizer}}
@content={{@llms.resultSetMeta.tokenizers}}
@class="ai-llm-editor__tokenizer"
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.max_prompt_tokens"}}</label>
<Input
@type="number"
class="ai-llm-editor-input ai-llm-editor__max-prompt-tokens"
step="any"
min="0"
lang="en"
@value={{@model.max_prompt_tokens}}
required="true"
/>
<DTooltip
@icon="circle-question"
@content={{i18n "discourse_ai.llms.hints.max_prompt_tokens"}}
/>
</div>
<div class="control-group ai-llm-editor__vision-enabled">
<Input @type="checkbox" @checked={{@model.vision_enabled}} />
<label>{{i18n "discourse_ai.llms.vision_enabled"}}</label>
<DTooltip
@icon="circle-question"
@content={{i18n "discourse_ai.llms.hints.vision_enabled"}}
/>
</div>
<div class="control-group ai-llm-editor__enabled-chat-bot">
<Input @type="checkbox" @checked={{@model.enabled_chat_bot}} />
<label>{{i18n "discourse_ai.llms.enabled_chat_bot"}}</label>
<DTooltip
@icon="circle-question"
@content={{i18n "discourse_ai.llms.hints.enabled_chat_bot"}}
/>
</div>
<field.Input />
</form.Field>
{{/if}}
<form.Field
@name="api_key"
@title={{i18n "discourse_ai.llms.api_key"}}
@validation="required"
@format="large"
as |field|
>
<field.Password />
</form.Field>
<form.Object @name="provider_params" as |object name|>
{{#let
(get (this.metaProviderParams data.provider) name)
as |params|
}}
<object.Field
@name={{name}}
@title={{i18n (concat "discourse_ai.llms.provider_fields." name)}}
@format="large"
as |field|
>
{{#if (eq params.type "enum")}}
<field.Select as |select|>
{{#each params.values as |option|}}
<select.Option
@value={{option.id}}
>{{option.name}}</select.Option>
{{/each}}
</field.Select>
{{else if (eq params.type "checkbox")}}
<field.Checkbox />
{{else}}
<field.Input @type={{params.type}} />
{{/if}}
</object.Field>
{{/let}}
</form.Object>
<form.Field
@name="tokenizer"
@title={{i18n "discourse_ai.llms.tokenizer"}}
@disabled={{this.seeded}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.tokenizers as |tokenizer|}}
<select.Option
@value={{tokenizer.id}}
>{{tokenizer.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
<form.Field
@name="max_prompt_tokens"
@title={{i18n "discourse_ai.llms.max_prompt_tokens"}}
@tooltip={{i18n "discourse_ai.llms.hints.max_prompt_tokens"}}
@validation="required"
@format="large"
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</form.Field>
<form.Field
@name="vision_enabled"
@title={{i18n "discourse_ai.llms.vision_enabled"}}
@tooltip={{i18n "discourse_ai.llms.hints.vision_enabled"}}
@format="large"
as |field|
>
<field.Checkbox />
</form.Field>
<form.Field
@name="enabled_chat_bot"
@title={{i18n "discourse_ai.llms.enabled_chat_bot"}}
@tooltip={{i18n "discourse_ai.llms.hints.enabled_chat_bot"}}
@format="large"
as |field|
>
<field.Checkbox />
</form.Field>
{{#if @model.user}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.ai_bot_user"}}</label>
<form.Container @title={{i18n "discourse_ai.llms.ai_bot_user"}}>
<a
class="avatar"
href={{@model.user.path}}
@ -370,76 +409,166 @@ export default class AiLlmEditorForm extends Component {
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{@model.user.username}}
</LinkTo>
</div>
</form.Container>
{{/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 (gt data.llm_quotas.length 0)}}
<form.Container @title={{i18n "discourse_ai.llms.quotas.title"}}>
<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>
<th></th>
</tr>
</thead>
<tbody class="ai-llm-quotas__table-body">
<form.Collection
@name="llm_quotas"
@tagName="tr"
class="ai-llm-quotas__row"
as |collection index collectionData|
>
<td
class="ai-llm-quotas__cell"
>{{collectionData.group_name}}</td>
<td class="ai-llm-quotas__cell">
<collection.Field
@name="max_tokens"
@title="max_tokens"
@showTitle={{false}}
as |field|
>
<field.Input
@type="number"
class="ai-llm-quotas__input"
min="1"
/>
</collection.Field>
</td>
<td class="ai-llm-quotas__cell">
<collection.Field
@name="max_usages"
@title="max_usages"
@showTitle={{false}}
as |field|
>
<field.Input
@type="number"
class="ai-llm-quotas__input"
min="1"
/>
</collection.Field>
</td>
<td class="ai-llm-quotas__cell">
<collection.Field
@name="duration_seconds"
@title="duration_seconds"
@showTitle={{false}}
as |field|
>
<field.Custom>
<DurationSelector
@value={{collectionData.duration_seconds}}
@onChange={{field.set}}
/>
</field.Custom>
</collection.Field>
</td>
<td>
<form.Button
@icon="trash-can"
@action={{fn collection.remove index}}
class="btn-danger ai-llm-quotas__delete-btn"
/>
</td>
</form.Collection>
</tbody>
</table>
</form.Container>
<form.Button
@action={{fn
this.openAddQuotaModal
(fn form.addItemToCollection "llm_quotas")
}}
@icon="plus"
@label="discourse_ai.llms.quotas.add"
class="ai-llm-editor__add-quota-btn"
/>
{{/if}}
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-llm-editor__test"
@action={{this.test}}
<form.Actions>
<form.Button
@action={{fn this.test data}}
@disabled={{this.testRunning}}
@label="discourse_ai.llms.tests.title"
/>
{{#if this.showAddQuotaButton}}
<DButton
@action={{this.openAddQuotaModal}}
<form.Submit />
{{#if (eq data.llm_quotas.length 0)}}
<form.Button
@action={{fn
this.openAddQuotaModal
(fn form.addItemToCollection "llm_quotas")
}}
@label="discourse_ai.llms.quotas.add"
class="btn"
class="ai-llm-editor__add-quota-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}}
@disabled={{this.isSaving}}
@label="discourse_ai.llms.save"
/>
{{#unless @model.isNew}}
<DButton
<form.Button
@action={{this.delete}}
class="btn-danger ai-llm-editor__delete"
@label="discourse_ai.llms.delete"
class="btn-danger"
/>
{{/unless}}
</div>
</form.Actions>
{{/unless}}
<div class="control-group ai-llm-editor-tests">
{{#if this.displayTestResult}}
{{#if this.testRunning}}
<div class="spinner small"></div>
{{i18n "discourse_ai.llms.tests.running"}}
{{else}}
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
{{/if}}
{{/if}}
</div>
</form>
{{#if this.displayTestResult}}
<form.Field
@showTitle={{false}}
@name="test_results"
@title="test_results"
@format="full"
as |field|
>
<field.Custom>
<ConditionalLoadingSpinner
@size="small"
@condition={{this.testRunning}}
>
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</field.Custom>
</form.Field>
{{/if}}
</Form>
</template>
}

View File

@ -1,41 +1,16 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import BackButton from "discourse/components/back-button";
import AiLlmEditorForm from "./ai-llm-editor-form";
export default class AiLlmEditor extends Component {
constructor() {
super(...arguments);
if (this.args.llmTemplate) {
this.configurePreset();
}
}
const AiLlmEditor = <template>
<BackButton
@route="adminPlugins.show.discourse-ai-llms"
@label="discourse_ai.llms.back"
/>
<AiLlmEditorForm
@model={{@model}}
@llmTemplate={{@llmTemplate}}
@llms={{@llms}}
/>
</template>;
@action
configurePreset() {
let [id, model] = this.args.llmTemplate.split(/-(.*)/);
if (id === "none") {
return;
}
const info = this.args.llms.resultSetMeta.presets.findBy("id", id);
const modelInfo = info.models.findBy("name", model);
this.args.model.setProperties({
max_prompt_tokens: modelInfo.tokens,
tokenizer: info.tokenizer,
url: modelInfo.endpoint || info.endpoint,
display_name: modelInfo.display_name,
name: modelInfo.name,
provider: info.provider,
});
}
<template>
<BackButton
@route="adminPlugins.show.discourse-ai-llms"
@label="discourse_ai.llms.back"
/>
<AiLlmEditorForm @model={{@model}} @llms={{@llms}} />
</template>
}
export default AiLlmEditor;

View File

@ -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-can"
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>
}

View File

@ -1,66 +1,26 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { cached } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
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 Form from "discourse/components/form";
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;
}
save(data) {
const quota = { ...data };
quota.group_name = this.site.groups.findBy("id", data.group_id).name;
quota.llm_model_id = this.args.model.id;
@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.model.addItemToCollection(quota);
this.args.closeModal();
if (this.args.model.onSave) {
this.args.model.onSave();
}
@ -75,6 +35,39 @@ export default class AiLlmQuotaModal extends Component {
);
}
@cached
get quota() {
return {
group_id: null,
llm_model_id: null,
max_tokens: null,
max_usages: null,
duration_seconds: moment.duration(1, "day").asSeconds(),
};
}
@action
setGroupId(field, groups) {
field.set(groups[0]);
}
@action
validateForm(data, { addError, removeError }) {
if (!data.max_tokens && !data.max_usages) {
addError("max_tokens", {
title: i18n("discourse_ai.llms.quotas.max_tokens"),
message: i18n("discourse_ai.llms.quotas.max_tokens_required"),
});
addError("max_usages", {
title: i18n("discourse_ai.llms.quotas.max_usages"),
message: i18n("discourse_ai.llms.quotas.max_usages_required"),
});
} else {
removeError("max_tokens");
removeError("max_usages");
}
}
<template>
<DModal
@title={{i18n "discourse_ai.llms.quotas.add_title"}}
@ -82,63 +75,70 @@ export default class AiLlmQuotaModal extends Component {
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>
<Form
@validate={{this.validateForm}}
@onSubmit={{this.save}}
@data={{this.quota}}
as |form data|
>
<form.Field
@name="group_id"
@title={{i18n "discourse_ai.llms.quotas.group"}}
@validation="required"
@format="large"
as |field|
>
<field.Custom>
<GroupChooser
@value={{data.group_id}}
@content={{this.availableGroups}}
@onChange={{fn this.setGroupId field}}
@options={{hash maximum=1}}
/>
</field.Custom>
</form.Field>
<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>
<form.Field
@name="max_tokens"
@title={{i18n "discourse_ai.llms.quotas.max_tokens"}}
@tooltip={{i18n "discourse_ai.llms.quotas.max_tokens_help"}}
@format="large"
as |field|
>
<field.Input @type="number" min="1" />
</form.Field>
<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>
<form.Field
@name="max_usages"
@title={{i18n "discourse_ai.llms.quotas.max_usages"}}
@tooltip={{i18n "discourse_ai.llms.quotas.max_usages_help"}}
@format="large"
as |field|
>
<field.Input @type="number" min="1" />
</form.Field>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.quotas.duration"}}</label>
<DurationSelector
@value={{this.duration}}
@onChange={{this.updateDuration}}
<form.Field
@name="duration_seconds"
@title={{i18n "discourse_ai.llms.quotas.duration"}}
@validation="required"
@format="large"
as |field|
>
<field.Custom>
<DurationSelector
@value={{data.duration_seconds}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
<form.Submit
@label="discourse_ai.llms.quotas.add"
class="btn-primary"
/>
</div>
</Form>
</:body>
<:footer>
<DButton
@action={{this.save}}
@label="discourse_ai.llms.quotas.add"
@disabled={{not this.canSave}}
class="btn-primary"
/>
</:footer>
</DModal>
</template>
}

View File

@ -15,24 +15,6 @@
.ai-llm-editor {
padding-left: 0.5em;
.ai-llm-editor-input {
width: 350px;
}
.ai-llm-editor-provider-param {
&__checkbox {
display: flex;
align-items: flex-start;
flex-direction: row-reverse;
justify-content: left;
}
}
.fk-d-tooltip__icon {
padding-left: 0.25em;
color: var(--primary-medium);
}
.ai-llm-editor-tests {
&__failure {
color: var(--danger);
@ -42,21 +24,6 @@
color: var(--success);
}
}
&__api-key {
margin-right: 0.5em;
}
&__secret-api-key-group {
display: flex;
align-items: center;
}
&__vision-enabled,
&__enabled-chat-bot {
display: flex;
align-items: flex-start;
}
}
[class*="ai-llms-list-editor"] {
@ -110,8 +77,8 @@
grid-template-columns: repeat(auto-fill, minmax(16em, 1fr));
gap: 1em 2em;
margin-top: 1em;
border-top: 3px solid var(--primary-low); // matches tbody border
padding-top: 1em;
border-top: 3px solid var(--primary-low);
}
&-list-item {
display: grid;

View File

@ -360,7 +360,9 @@ en:
custom: "Custom..."
hours: "hours"
max_tokens_help: "Maximum number of tokens (words and characters) that each user in this group can use within the specified duration. Tokens are the units used by AI models to process text - roughly 1 token = 4 characters or 3/4 of a word."
max_tokens_required: "Must be set if max usages is not set"
max_usages_help: "Maximum number of times each user in this group can use the AI model within the specified duration. This quota is tracked per individual user, not shared across the group."
max_usages_required: "Must be set if max tokens is not set"
usage:
ai_bot: "AI bot"
ai_helper: "Helper"

View File

@ -2,7 +2,9 @@
RSpec.describe "Managing LLM configurations", type: :system, js: true do
fab!(:admin)
let(:page_header) { PageObjects::Components::DPageHeader.new }
let(:form) { PageObjects::Components::FormKit.new("form") }
before do
SiteSetting.ai_bot_enabled = true
@ -13,17 +15,17 @@ RSpec.describe "Managing LLM configurations", type: :system, js: true do
visit "/admin/plugins/discourse-ai/ai-llms"
find("[data-llm-id='anthropic-claude-3-5-haiku'] button").click()
find("input.ai-llm-editor__api-key").fill_in(with: "abcd")
find(".ai-llm-editor__enabled-chat-bot input").click
find(".ai-llm-editor__save").click()
form.field("api_key").fill_in("abcd")
form.field("enabled_chat_bot").toggle
form.submit
expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-llms")
llm = LlmModel.order(:id).last
expect(llm.api_key).to eq("abcd")
preset = DiscourseAi::Completions::Llm.presets.find { |p| p[:id] == "anthropic" }
model_preset = preset[:models].find { |m| m[:name] == "claude-3-5-haiku" }
expect(llm.name).to eq("claude-3-5-haiku")
@ -37,27 +39,23 @@ RSpec.describe "Managing LLM configurations", type: :system, js: true do
it "manually configures an LLM" do
visit "/admin/plugins/discourse-ai/ai-llms"
expect(page_header).to be_visible
find("[data-llm-id='none'] button").click()
expect(page_header).to be_hidden
find("input.ai-llm-editor__display-name").fill_in(with: "Self-hosted LLM")
find("input.ai-llm-editor__name").fill_in(with: "llava-hf/llava-v1.6-mistral-7b-hf")
find("input.ai-llm-editor__url").fill_in(with: "srv://self-hostest.test")
find("input.ai-llm-editor__api-key").fill_in(with: "1234")
find("input.ai-llm-editor__max-prompt-tokens").fill_in(with: 8000)
find(".ai-llm-editor__provider").click
find(".select-kit-row[data-value=\"vllm\"]").click
find(".ai-llm-editor__tokenizer").click
find(".select-kit-row[data-name=\"Llama3Tokenizer\"]").click
find(".ai-llm-editor__vision-enabled input").click
find(".ai-llm-editor__enabled-chat-bot input").click
find(".ai-llm-editor__save").click()
form.field("display_name").fill_in("Self-hosted LLM")
form.field("name").fill_in("llava-hf/llava-v1.6-mistral-7b-hf")
form.field("url").fill_in("srv://self-hostest.test")
form.field("api_key").fill_in("1234")
form.field("max_prompt_tokens").fill_in(8000)
form.field("provider").select("vllm")
form.field("tokenizer").select("DiscourseAi::Tokenizer::Llama3Tokenizer")
form.field("vision_enabled").toggle
form.field("enabled_chat_bot").toggle
form.submit
expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-llms")
@ -73,6 +71,49 @@ RSpec.describe "Managing LLM configurations", type: :system, js: true do
expect(llm.user_id).not_to be_nil
end
context "with quotas" do
fab!(:llm_model_1) { Fabricate(:llm_model, name: "claude-2") }
fab!(:group_1) { Fabricate(:group) }
before { Fabricate(:llm_quota, group: group_1, llm_model: llm_model_1, max_tokens: 1000) }
it "prefills the quotas form" do
visit "/admin/plugins/discourse-ai/ai-llms/#{llm_model_1.id}/edit"
expect(page).to have_selector(
".ai-llm-quotas__table .ai-llm-quotas__cell",
text: group_1.name,
)
end
it "can remove a quota" do
visit "/admin/plugins/discourse-ai/ai-llms/#{llm_model_1.id}/edit"
find(".ai-llm-quotas__delete-btn:nth-child(1)").click
expect(page).to have_no_selector(
".ai-llm-quotas__table .ai-llm-quotas__cell",
text: group_1.name,
)
end
it "can add a quota" do
visit "/admin/plugins/discourse-ai/ai-llms/#{llm_model_1.id}/edit"
find(".ai-llm-editor__add-quota-btn").click
select_kit = PageObjects::Components::SelectKit.new(".group-chooser")
select_kit.expand
select_kit.select_row_by_value(1)
form = PageObjects::Components::FormKit.new(".ai-llm-quota-modal form")
form.field("max_tokens").fill_in(2000)
form.submit
expect(page).to have_selector(
".ai-llm-quotas__table .ai-llm-quotas__cell",
text: Group.find(1).name,
)
end
end
context "when seeded LLM is present" do
fab!(:llm_model) { Fabricate(:seeded_model) }