form.Object and form.Collection have been changed. Most importantly formObject doesnt yield name but data. It's not your responsibility to build an array of fields.
576 lines
16 KiB
Plaintext
576 lines
16 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 { later } from "@ember/runloop";
|
|
import { service } from "@ember/service";
|
|
import { eq, not } from "truth-helpers";
|
|
import BackButton from "discourse/components/back-button";
|
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
|
import DButton from "discourse/components/d-button";
|
|
import Form from "discourse/components/form";
|
|
import icon from "discourse/helpers/d-icon";
|
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
import { i18n } from "discourse-i18n";
|
|
import AdminSectionLandingItem from "admin/components/admin-section-landing-item";
|
|
import AdminSectionLandingWrapper from "admin/components/admin-section-landing-wrapper";
|
|
|
|
export default class AiEmbeddingEditor extends Component {
|
|
@service toasts;
|
|
@service router;
|
|
@service dialog;
|
|
@service store;
|
|
|
|
@tracked isSaving = false;
|
|
@tracked selectedPreset = null;
|
|
@tracked testRunning = false;
|
|
@tracked testResult = null;
|
|
@tracked testError = null;
|
|
@tracked currentProvider = null;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
if (this.args.model) {
|
|
this.currentProvider = this.args.model.provider;
|
|
}
|
|
}
|
|
|
|
@cached
|
|
get formData() {
|
|
let data;
|
|
|
|
if (this.selectedPreset) {
|
|
data = this.store
|
|
.createRecord("ai-embedding", this.selectedPreset)
|
|
.workingCopy();
|
|
} else {
|
|
data = this.args.model.workingCopy();
|
|
}
|
|
|
|
const originalData = JSON.parse(JSON.stringify(data));
|
|
this._originalFormData = originalData;
|
|
|
|
return originalData;
|
|
}
|
|
|
|
get selectedProviders() {
|
|
const t = (provName) => {
|
|
return i18n(`discourse_ai.embeddings.providers.${provName}`);
|
|
};
|
|
|
|
return this.args.embeddings.resultSetMeta.providers.map((prov) => {
|
|
return { id: prov, name: t(prov) };
|
|
});
|
|
}
|
|
|
|
get distanceFunctions() {
|
|
const t = (df) => {
|
|
return i18n(`discourse_ai.embeddings.distance_functions.${df}`);
|
|
};
|
|
|
|
return this.args.embeddings.resultSetMeta.distance_functions.map((df) => {
|
|
return {
|
|
id: df,
|
|
name: t(df),
|
|
};
|
|
});
|
|
}
|
|
|
|
get presets() {
|
|
const presets = this.args.embeddings.resultSetMeta.presets.map((preset) => {
|
|
return {
|
|
name: preset.display_name,
|
|
id: preset.preset_id,
|
|
provider: preset.provider,
|
|
};
|
|
});
|
|
|
|
presets.unshiftObject({
|
|
name: i18n("discourse_ai.embeddings.configure_manually"),
|
|
id: "manual",
|
|
provider: "fake",
|
|
});
|
|
|
|
return presets;
|
|
}
|
|
|
|
get showPresets() {
|
|
return !this.selectedPreset && this.args.model.isNew;
|
|
}
|
|
|
|
get metaProviderParams() {
|
|
const provider = this.currentProvider;
|
|
if (!provider) {
|
|
return {};
|
|
}
|
|
|
|
const embeddings = this.args.embeddings || {};
|
|
const meta = embeddings.resultSetMeta || {};
|
|
const providerParams = meta.provider_params || {};
|
|
|
|
return providerParams[provider] || {};
|
|
}
|
|
|
|
get testErrorMessage() {
|
|
return i18n("discourse_ai.llms.tests.failure", { error: this.testError });
|
|
}
|
|
|
|
get displayTestResult() {
|
|
return this.testRunning || this.testResult !== null;
|
|
}
|
|
|
|
get seeded() {
|
|
return this.args.model.id < 0;
|
|
}
|
|
|
|
@action
|
|
configurePreset(preset) {
|
|
this.selectedPreset =
|
|
this.args.embeddings.resultSetMeta.presets.findBy(
|
|
"preset_id",
|
|
preset.id
|
|
) || {};
|
|
|
|
if (this.selectedPreset.provider) {
|
|
this.currentProvider = this.selectedPreset.provider;
|
|
}
|
|
}
|
|
|
|
@action
|
|
setProvider(provider, { set }) {
|
|
set("provider", provider);
|
|
|
|
this.currentProvider = provider;
|
|
|
|
const providerParams =
|
|
this.args.embeddings?.resultSetMeta?.provider_params || {};
|
|
const params = providerParams[provider] || {};
|
|
|
|
const initialParams = {};
|
|
|
|
if (params) {
|
|
const keys = Object.keys(params);
|
|
keys.forEach((key) => {
|
|
initialParams[key] = null;
|
|
});
|
|
}
|
|
|
|
set("provider_params", initialParams);
|
|
}
|
|
|
|
get providerParams() {
|
|
const normalizeParam = (value) => {
|
|
if (!value) {
|
|
return { type: "text" };
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return { type: value };
|
|
}
|
|
|
|
return {
|
|
type: value.type || "text",
|
|
values: (value.values || []).map((v) => ({ id: v, name: v })),
|
|
default: value.default,
|
|
};
|
|
};
|
|
|
|
return Object.entries(this.metaProviderParams).reduce(
|
|
(acc, [field, value]) => {
|
|
acc[field] = normalizeParam(value);
|
|
return acc;
|
|
},
|
|
{}
|
|
);
|
|
}
|
|
|
|
@action
|
|
resetForm() {
|
|
this.selectedPreset = null;
|
|
this.currentProvider = null;
|
|
}
|
|
|
|
@action
|
|
async save(formData) {
|
|
this.isSaving = true;
|
|
const isNew = this.args.model.isNew;
|
|
|
|
try {
|
|
const dataToSave = { ...formData };
|
|
|
|
if (this.selectedPreset) {
|
|
// new embeddings
|
|
const newModel = this.store.createRecord("ai-embedding", {
|
|
...this.selectedPreset,
|
|
...dataToSave,
|
|
});
|
|
await newModel.save();
|
|
this.args.embeddings.addObject(newModel);
|
|
} else {
|
|
// existing embeddings
|
|
await this.args.model.save(dataToSave);
|
|
}
|
|
|
|
if (isNew) {
|
|
this.router.transitionTo(
|
|
"adminPlugins.show.discourse-ai-embeddings.index"
|
|
);
|
|
} else {
|
|
const savedProvider = this.currentProvider;
|
|
|
|
this._originalFormData = JSON.parse(JSON.stringify(dataToSave));
|
|
this.currentProvider = savedProvider;
|
|
|
|
this.toasts.success({
|
|
data: { message: i18n("discourse_ai.embeddings.saved") },
|
|
duration: 2000,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
popupAjaxError(e);
|
|
} finally {
|
|
later(() => {
|
|
this.isSaving = false;
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
@action
|
|
async test(data) {
|
|
this.testRunning = true;
|
|
|
|
try {
|
|
let testModel;
|
|
|
|
// new embeddings
|
|
if (this.args.model.isNew || this.selectedPreset) {
|
|
testModel = this.store.createRecord("ai-embedding", {
|
|
...this.selectedPreset,
|
|
...data,
|
|
});
|
|
} else {
|
|
// existing embeddings
|
|
testModel = this.args.model;
|
|
}
|
|
|
|
const configTestResult = await testModel.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
|
|
delete() {
|
|
return this.dialog.confirm({
|
|
message: i18n("discourse_ai.embeddings.confirm_delete"),
|
|
didConfirm: () => {
|
|
return this.args.model
|
|
.destroyRecord()
|
|
.then(() => {
|
|
this.args.embeddings.removeObject(this.args.model);
|
|
this.router.transitionTo(
|
|
"adminPlugins.show.discourse-ai-embeddings.index"
|
|
);
|
|
})
|
|
.catch(popupAjaxError);
|
|
},
|
|
});
|
|
}
|
|
|
|
<template>
|
|
{{#if this.showPresets}}
|
|
<BackButton
|
|
@route="adminPlugins.show.discourse-ai-embeddings"
|
|
@label="discourse_ai.embeddings.back"
|
|
/>
|
|
<div class="control-group">
|
|
<h2>{{i18n "discourse_ai.embeddings.presets"}}</h2>
|
|
<AdminSectionLandingWrapper>
|
|
{{#each this.presets as |preset|}}
|
|
<AdminSectionLandingItem
|
|
@titleLabelTranslated={{preset.name}}
|
|
@taglineLabel={{concat
|
|
"discourse_ai.embeddings.providers."
|
|
preset.provider
|
|
}}
|
|
data-preset-id={{preset.id}}
|
|
class="ai-llms-list-editor__templates-list-item"
|
|
>
|
|
<:buttons as |buttons|>
|
|
<buttons.Default
|
|
@action={{fn this.configurePreset preset}}
|
|
@icon="gear"
|
|
@label="discourse_ai.llms.preconfigured.button"
|
|
/>
|
|
</:buttons>
|
|
</AdminSectionLandingItem>
|
|
{{/each}}
|
|
</AdminSectionLandingWrapper>
|
|
</div>
|
|
{{else}}
|
|
<Form
|
|
@onSubmit={{this.save}}
|
|
@data={{this.formData}}
|
|
class="form-horizontal ai-embedding-editor {{if this.seeded 'seeded'}}"
|
|
as |form data|
|
|
>
|
|
{{#if @model.isNew}}
|
|
<DButton
|
|
@action={{this.resetForm}}
|
|
@label="back_button"
|
|
@icon="chevron-left"
|
|
class="btn-flat back-button"
|
|
/>
|
|
{{else}}
|
|
<BackButton
|
|
@route="adminPlugins.show.discourse-ai-embeddings"
|
|
@label="discourse_ai.embeddings.back"
|
|
/>
|
|
{{/if}}
|
|
|
|
<form.Field
|
|
@name="display_name"
|
|
@title={{i18n "discourse_ai.embeddings.display_name"}}
|
|
@validation="required|length:1,100"
|
|
@format="large"
|
|
class="ai-embedding-editor__display-name"
|
|
as |field|
|
|
>
|
|
<field.Input />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="provider"
|
|
@title={{i18n "discourse_ai.embeddings.provider"}}
|
|
@validation="required"
|
|
@format="large"
|
|
@onSet={{this.setProvider}}
|
|
class="ai-embedding-editor__provider"
|
|
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>
|
|
|
|
<form.Field
|
|
@name="url"
|
|
@title={{i18n "discourse_ai.embeddings.url"}}
|
|
@validation="required"
|
|
@format="large"
|
|
class="ai-embedding-editor__url"
|
|
as |field|
|
|
>
|
|
<field.Input />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="api_key"
|
|
@title={{i18n "discourse_ai.embeddings.api_key"}}
|
|
@validation="required"
|
|
@format="large"
|
|
class="ai-embedding-editor__api-key"
|
|
as |field|
|
|
>
|
|
<field.Password />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="tokenizer_class"
|
|
@title={{i18n "discourse_ai.embeddings.tokenizer"}}
|
|
@validation="required"
|
|
@format="large"
|
|
class="ai-embedding-editor__tokenizer"
|
|
as |field|
|
|
>
|
|
<field.Select as |select|>
|
|
{{#each @embeddings.resultSetMeta.tokenizers as |tokenizer|}}
|
|
<select.Option
|
|
@value={{tokenizer.id}}
|
|
>{{tokenizer.name}}</select.Option>
|
|
{{/each}}
|
|
</field.Select>
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="dimensions"
|
|
@title={{i18n "discourse_ai.embeddings.dimensions"}}
|
|
@validation="required"
|
|
@format="large"
|
|
@tooltip={{if
|
|
@model.isNew
|
|
(i18n "discourse_ai.embeddings.hints.dimensions_warning")
|
|
}}
|
|
class="ai-embedding-editor__dimensions"
|
|
as |field|
|
|
>
|
|
<field.Input
|
|
@type="number"
|
|
step="any"
|
|
min="0"
|
|
lang="en"
|
|
disabled={{not @model.isNew}}
|
|
/>
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="matryoshka_dimensions"
|
|
@title={{i18n "discourse_ai.embeddings.matryoshka_dimensions"}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.embeddings.hints.matryoshka_dimensions"
|
|
}}
|
|
@format="large"
|
|
class="ai-embedding-editor__matryoshka_dimensions"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="embed_prompt"
|
|
@title={{i18n "discourse_ai.embeddings.embed_prompt"}}
|
|
@tooltip={{i18n "discourse_ai.embeddings.hints.embed_prompt"}}
|
|
@format="large"
|
|
class="ai-embedding-editor__embed_prompt"
|
|
as |field|
|
|
>
|
|
<field.Textarea />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="search_prompt"
|
|
@title={{i18n "discourse_ai.embeddings.search_prompt"}}
|
|
@tooltip={{i18n "discourse_ai.embeddings.hints.search_prompt"}}
|
|
@format="large"
|
|
class="ai-embedding-editor__search_prompt"
|
|
as |field|
|
|
>
|
|
<field.Textarea />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="max_sequence_length"
|
|
@title={{i18n "discourse_ai.embeddings.max_sequence_length"}}
|
|
@tooltip={{i18n "discourse_ai.embeddings.hints.sequence_length"}}
|
|
@validation="required"
|
|
@format="large"
|
|
class="ai-embedding-editor__max_sequence_length"
|
|
as |field|
|
|
>
|
|
<field.Input @type="number" step="any" min="0" lang="en" />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="pg_function"
|
|
@title={{i18n "discourse_ai.embeddings.distance_function"}}
|
|
@tooltip={{i18n "discourse_ai.embeddings.hints.distance_function"}}
|
|
@format="large"
|
|
@validation="required"
|
|
class="ai-embedding-editor__distance_functions"
|
|
as |field|
|
|
>
|
|
<field.Select @includeNone={{false}} as |select|>
|
|
{{#each this.distanceFunctions as |df|}}
|
|
<select.Option @value={{df.id}}>{{df.name}}</select.Option>
|
|
{{/each}}
|
|
</field.Select>
|
|
</form.Field>
|
|
|
|
{{! provider-specific content }}
|
|
{{#if this.currentProvider}}
|
|
<form.Object @name="provider_params" as |object providerData|>
|
|
{{#each-in providerData as |name|}}
|
|
{{#let (get this.providerParams name) as |params|}}
|
|
{{#if params}}
|
|
<object.Field
|
|
@name={{name}}
|
|
@title={{i18n
|
|
(concat "discourse_ai.embeddings.provider_fields." name)
|
|
}}
|
|
@format="large"
|
|
@validation="required"
|
|
class="ai-embedding-editor-provider-param__{{params.type}}"
|
|
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>
|
|
{{/if}}
|
|
{{/let}}
|
|
{{/each-in}}
|
|
</form.Object>
|
|
{{/if}}
|
|
|
|
<form.Actions class="ai-embedding-editor__action_panel">
|
|
<form.Button
|
|
@action={{fn this.test data}}
|
|
@disabled={{this.testRunning}}
|
|
@label="discourse_ai.embeddings.tests.title"
|
|
class="ai-embedding-editor__test"
|
|
/>
|
|
|
|
<form.Submit
|
|
@label="discourse_ai.embeddings.save"
|
|
class="btn-primary ai-embedding-editor__save"
|
|
/>
|
|
|
|
{{#unless data.isNew}}
|
|
<form.Button
|
|
@action={{this.delete}}
|
|
@label="discourse_ai.embeddings.delete"
|
|
class="btn-danger ai-embedding-editor__delete"
|
|
/>
|
|
{{/unless}}
|
|
</form.Actions>
|
|
|
|
{{#if this.displayTestResult}}
|
|
<form.Container @format="full" class="ai-embedding-editor-tests">
|
|
<ConditionalLoadingSpinner
|
|
@size="small"
|
|
@condition={{this.testRunning}}
|
|
>
|
|
{{#if this.testResult}}
|
|
<div class="ai-embedding-editor-tests__success">
|
|
{{icon "check"}}
|
|
{{i18n "discourse_ai.embeddings.tests.success"}}
|
|
</div>
|
|
{{else}}
|
|
<div class="ai-embedding-editor-tests__failure">
|
|
{{icon "xmark"}}
|
|
{{this.testErrorMessage}}
|
|
</div>
|
|
{{/if}}
|
|
</ConditionalLoadingSpinner>
|
|
</form.Container>
|
|
{{/if}}
|
|
</Form>
|
|
{{/if}}
|
|
</template>
|
|
}
|