From 40e996b174332030762b29c4fb80557aa9c9151c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 4 Feb 2025 11:51:01 +0100 Subject: [PATCH] DEV: converts llm admin page to use form kit (#1099) This also converts the quota editor, and the quota modal. --- .../discourse/admin/models/ai-llm.js | 6 +- .../components/ai-llm-editor-form.gjs | 657 +++++++++++------- .../discourse/components/ai-llm-editor.gjs | 49 +- .../components/ai-llm-quota-editor.gjs | 178 ----- .../components/modal/ai-llm-quota-modal.gjs | 202 +++--- .../modules/llms/common/ai-llms-editor.scss | 35 +- config/locales/client.en.yml | 2 + spec/system/llms/ai_llm_spec.rb | 81 ++- 8 files changed, 573 insertions(+), 637 deletions(-) delete mode 100644 assets/javascripts/discourse/components/ai-llm-quota-editor.gjs diff --git a/assets/javascripts/discourse/admin/models/ai-llm.js b/assets/javascripts/discourse/admin/models/ai-llm.js index c6567cfe..ec4bb13d 100644 --- a/assets/javascripts/discourse/admin/models/ai-llm.js +++ b/assets/javascripts/discourse/admin/models/ai-llm.js @@ -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() }, }); } } diff --git a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs index fb28de8a..d48468d6 100644 --- a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs +++ b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs @@ -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(); - } - } diff --git a/assets/javascripts/discourse/components/ai-llm-editor.gjs b/assets/javascripts/discourse/components/ai-llm-editor.gjs index 29beb1fc..e8fac7d6 100644 --- a/assets/javascripts/discourse/components/ai-llm-editor.gjs +++ b/assets/javascripts/discourse/components/ai-llm-editor.gjs @@ -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 = ; - @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, - }); - } - - -} +export default AiLlmEditor; diff --git a/assets/javascripts/discourse/components/ai-llm-quota-editor.gjs b/assets/javascripts/discourse/components/ai-llm-quota-editor.gjs deleted file mode 100644 index a40f2f11..00000000 --- a/assets/javascripts/discourse/components/ai-llm-quota-editor.gjs +++ /dev/null @@ -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; - } - - -} diff --git a/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs b/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs index e0f02e38..3c82d88a 100644 --- a/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs +++ b/assets/javascripts/discourse/components/modal/ai-llm-quota-modal.gjs @@ -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"); + } + } + } diff --git a/assets/stylesheets/modules/llms/common/ai-llms-editor.scss b/assets/stylesheets/modules/llms/common/ai-llms-editor.scss index 5f62a47a..1219a2ff 100644 --- a/assets/stylesheets/modules/llms/common/ai-llms-editor.scss +++ b/assets/stylesheets/modules/llms/common/ai-llms-editor.scss @@ -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; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 203ac460..0281c890 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/spec/system/llms/ai_llm_spec.rb b/spec/system/llms/ai_llm_spec.rb index 76ee8cae..5dc2e6dd 100644 --- a/spec/system/llms/ai_llm_spec.rb +++ b/spec/system/llms/ai_llm_spec.rb @@ -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) }