discourse-ai/assets/javascripts/discourse/components/ai-llm-editor-form.gjs

633 lines
18 KiB
Plaintext

import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { concat, fn, get } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { service } from "@ember/service";
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 icon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
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;
@cached
get formData() {
if (this.args.llmTemplate) {
let [id, modelName] = this.args.llmTemplate.split(/-(.*)/);
if (id === "none") {
return { provider_params: {} };
}
const info = this.args.llms.resultSetMeta.presets.findBy("id", id);
const modelInfo = info.models.findBy("name", modelName);
return {
max_prompt_tokens: modelInfo.tokens,
max_output_tokens: modelInfo.max_output_tokens,
tokenizer: info.tokenizer,
url: modelInfo.endpoint || info.endpoint,
display_name: modelInfo.display_name,
name: modelInfo.name,
provider: info.provider,
provider_params: this.computeProviderParams(info.provider),
input_cost: modelInfo.input_cost,
output_cost: modelInfo.output_cost,
cached_input_cost: modelInfo.cached_input_cost,
};
}
const { model } = this.args;
return {
max_prompt_tokens: model.max_prompt_tokens,
max_output_tokens: model.max_output_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,
input_cost: model.input_cost,
output_cost: model.output_cost,
cached_input_cost: model.cached_input_cost,
provider_params: this.computeProviderParams(
model.provider,
model.provider_params
),
llm_quotas: model.llm_quotas,
};
}
get selectedProviders() {
const t = (provName) => {
return i18n(`discourse_ai.llms.providers.${provName}`);
};
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() {
return AdminUser.create(this.args.model?.user);
}
get testErrorMessage() {
return i18n("discourse_ai.llms.tests.failure", { error: this.testError });
}
get displayTestResult() {
return this.testRunning || this.testResult !== null;
}
get modulesUsingModel() {
const usedBy = this.args.model.used_by?.filter((m) => m.type !== "ai_bot");
if (!usedBy || usedBy.length === 0) {
return null;
}
const localized = usedBy.map((m) => {
return i18n(`discourse_ai.llms.usage.${m.type}`, {
persona: m.name,
});
});
// TODO: this is not perfectly localized
return localized.join(", ");
}
get inUseWarning() {
return i18n("discourse_ai.llms.in_use_warning", {
settings: this.modulesUsingModel,
count: this.args.model.used_by.length,
});
}
get showAddQuotaButton() {
return !this.args.model.isNew;
}
computeProviderParams(provider, currentParams = {}) {
const params = this.args.llms.resultSetMeta.provider_params[provider] ?? {};
return Object.fromEntries(
Object.entries(params).map(([k, v]) => [
k,
currentParams[k] ?? (v?.type === "enum" ? v.default : null),
])
);
}
@action
canEditURL(provider) {
return provider !== "aws_bedrock";
}
@action
openAddQuotaModal(addItemToCollection) {
this.modal.show(AiLlmQuotaModal, {
model: { llm: this.args.model, addItemToCollection },
});
}
@action
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;
const updatedData = {
...data,
};
// If max_prompt_tokens input is cleared,
// we want the db to store null
if (!data.max_output_tokens) {
updatedData.max_output_tokens = null;
}
try {
await this.args.model.save(updatedData);
if (isNew) {
this.args.llms.addObject(this.args.model);
await this.router.replaceWith(
"adminPlugins.show.discourse-ai-llms.edit",
this.args.model.id
);
}
this.toasts.success({
data: { message: i18n("discourse_ai.llms.saved") },
duration: 2000,
});
} catch (e) {
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
@action
async test(data) {
this.testRunning = true;
try {
const configTestResult = await this.args.model.testConfig(data);
this.testResult = configTestResult.success;
if (this.testResult) {
this.testError = null;
} else {
this.testError = configTestResult.error;
}
} catch (e) {
popupAjaxError(e);
} finally {
later(() => {
this.testRunning = false;
}, 1000);
}
}
@action
setProvider(provider, { set }) {
set("provider_params", this.computeProviderParams(provider));
set("provider", provider);
}
@action
delete() {
return this.dialog.confirm({
message: i18n("discourse_ai.llms.confirm_delete"),
didConfirm: () => {
return this.args.model
.destroyRecord()
.then(() => {
this.args.llms.removeObject(this.args.model);
this.router.transitionTo(
"adminPlugins.show.discourse-ai-llms.index"
);
})
.catch(popupAjaxError);
},
});
}
@action
providerParamsKeys(providerParams) {
return providerParams ? Object.keys(providerParams) : [];
}
<template>
<Form
@onSubmit={{this.save}}
@data={{this.formData}}
class="ai-llm-editor"
as |form data|
>
{{#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"
@format="large"
@tooltip={{i18n "discourse_ai.llms.hints.display_name"}}
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"
@format="large"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="provider"
@title={{i18n "discourse_ai.llms.provider"}}
@format="large"
@validation="required"
@onSet={{this.setProvider}}
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>
{{#if (this.canEditURL data.provider)}}
<form.Field
@name="url"
@title={{i18n "discourse_ai.llms.url"}}
@validation="required"
@format="large"
as |field|
>
<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 autocomplete="off" data-1p-ignore />
</form.Field>
<form.Object @name="provider_params" as |object providerParamsData|>
{{#each (this.providerParamsKeys providerParamsData) as |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 @includeNone={{false}} 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}}
{{/each}}
</form.Object>
<form.Field
@name="tokenizer"
@title={{i18n "discourse_ai.llms.tokenizer"}}
@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.InputGroup as |inputGroup|>
<inputGroup.Field
@name="input_cost"
@title={{i18n "discourse_ai.llms.cost_input"}}
@tooltip={{i18n "discourse_ai.llms.hints.cost_input"}}
@helpText={{i18n "discourse_ai.llms.hints.cost_measure"}}
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</inputGroup.Field>
<inputGroup.Field
@name="cached_input_cost"
@title={{i18n "discourse_ai.llms.cost_cached_input"}}
@tooltip={{i18n "discourse_ai.llms.hints.cost_cached_input"}}
@helpText={{i18n "discourse_ai.llms.hints.cost_measure"}}
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</inputGroup.Field>
<inputGroup.Field
@name="output_cost"
@title={{i18n "discourse_ai.llms.cost_output"}}
@tooltip={{i18n "discourse_ai.llms.hints.cost_output"}}
@helpText={{i18n "discourse_ai.llms.hints.cost_measure"}}
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</inputGroup.Field>
</form.InputGroup>
<form.Field
@name="max_output_tokens"
@title={{i18n "discourse_ai.llms.max_output_tokens"}}
@tooltip={{i18n "discourse_ai.llms.hints.max_output_tokens"}}
@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}}
<form.Container @title={{i18n "discourse_ai.llms.ai_bot_user"}}>
<a
class="avatar"
href={{@model.user.path}}
data-user-card={{@model.user.username}}
>
{{Avatar @model.user.avatar_template "small"}}
</a>
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{@model.user.username}}
</LinkTo>
</form.Container>
{{/if}}
{{#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}}
<form.Actions>
<form.Button
@action={{fn this.test data}}
@disabled={{this.testRunning}}
@label="discourse_ai.llms.tests.title"
/>
<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="ai-llm-editor__add-quota-btn"
/>
{{/if}}
{{#unless @model.isNew}}
<form.Button
@action={{this.delete}}
@label="discourse_ai.llms.delete"
class="btn-danger"
/>
{{/unless}}
</form.Actions>
{{#if this.displayTestResult}}
<form.Container @format="full">
<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>
</form.Container>
{{/if}}
</Form>
</template>
}