discourse-ai/assets/javascripts/discourse/components/ai-persona-editor.gjs

574 lines
17 KiB
Plaintext

import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button";
import Textarea from "discourse/components/d-textarea";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import Avatar from "discourse/helpers/bound-avatar-template";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
import I18n from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import ComboBox from "select-kit/components/combo-box";
import GroupChooser from "select-kit/components/group-chooser";
import DTooltip from "float-kit/components/d-tooltip";
import AiCommandSelector from "./ai-command-selector";
import AiLlmSelector from "./ai-llm-selector";
import AiPersonaCommandOptions from "./ai-persona-command-options";
import PersonaRagUploader from "./persona-rag-uploader";
export default class PersonaEditor extends Component {
@service router;
@service store;
@service dialog;
@service toasts;
@service siteSettings;
@tracked allGroups = [];
@tracked isSaving = false;
@tracked editingModel = null;
@tracked showDelete = false;
@tracked maxPixelsValue = null;
@tracked ragIndexingStatuses = null;
@tracked showIndexingOptions = false;
@action
updateModel() {
this.editingModel = this.args.model.workingCopy();
this.showDelete = !this.args.model.isNew && !this.args.model.system;
this.maxPixelsValue = this.findClosestPixelValue(
this.editingModel.vision_max_pixels
);
}
@action
toggleIndexingOptions(event) {
this.showIndexingOptions = !this.showIndexingOptions;
event.preventDefault();
event.stopPropagation();
}
findClosestPixelValue(pixels) {
let value = "high";
this.maxPixelValues.forEach((info) => {
if (pixels === info.pixels) {
value = info.id;
}
});
return value;
}
@cached
get maxPixelValues() {
const l = (key) =>
I18n.t(`discourse_ai.ai_persona.vision_max_pixel_sizes.${key}`);
return [
{ id: "low", name: l("low"), pixels: 65536 },
{ id: "medium", name: l("medium"), pixels: 262144 },
{ id: "high", name: l("high"), pixels: 1048576 },
];
}
get indexingOptionsText() {
return this.showIndexingOptions
? I18n.t("discourse_ai.ai_persona.hide_indexing_options")
: I18n.t("discourse_ai.ai_persona.show_indexing_options");
}
@action
async updateAllGroups() {
this.allGroups = await Group.findAll();
}
@action
async save() {
const isNew = this.args.model.isNew;
this.isSaving = true;
const backupModel = this.args.model.workingCopy();
this.args.model.setProperties(this.editingModel);
try {
await this.args.model.save();
this.#sortPersonas();
if (isNew && this.args.model.rag_uploads.length === 0) {
this.args.personas.addObject(this.args.model);
this.router.transitionTo(
"adminPlugins.show.discourse-ai.ai-personas.show",
this.args.model
);
} else {
this.toasts.success({
data: { message: I18n.t("discourse_ai.ai_persona.saved") },
duration: 2000,
});
}
} catch (e) {
this.args.model.setProperties(backupModel);
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
get showTemperature() {
return this.editingModel?.temperature || !this.editingModel?.system;
}
get showTopP() {
return this.editingModel?.top_p || !this.editingModel?.system;
}
get adminUser() {
return AdminUser.create(this.editingModel?.user);
}
get mappedQuestionConsolidatorLlm() {
return this.editingModel?.question_consolidator_llm || "blank";
}
set mappedQuestionConsolidatorLlm(value) {
if (value === "blank") {
this.editingModel.question_consolidator_llm = null;
} else {
this.editingModel.question_consolidator_llm = value;
}
}
get mappedDefaultLlm() {
return this.editingModel?.default_llm || "blank";
}
set mappedDefaultLlm(value) {
if (value === "blank") {
this.editingModel.default_llm = null;
} else {
this.editingModel.default_llm = value;
}
}
@action
onChangeMaxPixels(value) {
const entry = this.maxPixelValues.findBy("id", value);
if (!entry) {
return;
}
this.maxPixelsValue = value;
this.editingModel.vision_max_pixels = entry.pixels;
}
@action
delete() {
return this.dialog.confirm({
message: I18n.t("discourse_ai.ai_persona.confirm_delete"),
didConfirm: () => {
return this.args.model.destroyRecord().then(() => {
this.args.personas.removeObject(this.args.model);
this.router.transitionTo(
"adminPlugins.show.discourse-ai.ai-personas.index"
);
});
},
});
}
@action
updateAllowedGroups(ids) {
this.editingModel.set("allowed_group_ids", ids);
}
@action
async toggleEnabled() {
await this.toggleField("enabled");
}
@action
async togglePriority() {
await this.toggleField("priority", true);
}
@action
async toggleMentionable() {
await this.toggleField("mentionable");
}
@action
async toggleVisionEnabled() {
await this.toggleField("vision_enabled");
}
@action
async createUser() {
try {
let user = await this.args.model.createUser();
this.editingModel.set("user", user);
this.editingModel.set("user_id", user.id);
} catch (e) {
popupAjaxError(e);
}
}
@action
updateUploads(uploads) {
this.editingModel.rag_uploads = uploads;
}
@action
removeUpload(upload) {
this.editingModel.rag_uploads.removeObject(upload);
if (!this.args.model.isNew) {
this.save();
}
}
async toggleField(field, sortPersonas) {
this.args.model.set(field, !this.args.model[field]);
this.editingModel.set(field, this.args.model[field]);
if (!this.args.model.isNew) {
try {
const args = {};
args[field] = this.args.model[field];
await this.args.model.update(args);
if (sortPersonas) {
this.#sortPersonas();
}
} catch (e) {
popupAjaxError(e);
}
}
}
#sortPersonas() {
const sorted = this.args.personas.toArray().sort((a, b) => {
if (a.priority && !b.priority) {
return -1;
} else if (!a.priority && b.priority) {
return 1;
} else {
return a.name.localeCompare(b.name);
}
});
this.args.personas.clear();
this.args.personas.setObjects(sorted);
}
<template>
<BackButton
@route="adminPlugins.show.discourse-ai.ai-personas"
@label="discourse_ai.ai_persona.back"
/>
<form
class="form-horizontal ai-persona-editor"
{{didUpdate this.updateModel @model.id}}
{{didInsert this.updateModel @model.id}}
{{didInsert this.updateAllGroups @model.id}}
>
<div class="control-group">
<DToggleSwitch
class="ai-persona-editor__enabled"
@state={{@model.enabled}}
@label="discourse_ai.ai_persona.enabled"
{{on "click" this.toggleEnabled}}
/>
</div>
<div class="control-group ai-persona-editor__priority">
<DToggleSwitch
class="ai-persona-editor__priority"
@state={{@model.priority}}
@label="discourse_ai.ai_persona.priority"
{{on "click" this.togglePriority}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
/>
</div>
{{#if this.editingModel.user}}
<div class="control-group ai-persona-editor__mentionable">
<DToggleSwitch
class="ai-persona-editor__mentionable_toggle"
@state={{@model.mentionable}}
@label="discourse_ai.ai_persona.mentionable"
{{on "click" this.toggleMentionable}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.mentionable_help"}}
/>
</div>
{{/if}}
<div class="control-group ai-persona-editor__vision_enabled">
<DToggleSwitch
@state={{@model.vision_enabled}}
@label="discourse_ai.ai_persona.vision_enabled"
{{on "click" this.toggleVisionEnabled}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.vision_enabled_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
<Input
class="ai-persona-editor__name"
@type="text"
@value={{this.editingModel.name}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.description"}}</label>
<Textarea
class="ai-persona-editor__description"
@value={{this.editingModel.description}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.default_llm"}}</label>
<AiLlmSelector
class="ai-persona-editor__llms"
@value={{this.mappedDefaultLlm}}
@llms={{@personas.resultSetMeta.llms}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.default_llm_help"}}
/>
</div>
{{#unless @model.isNew}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.user"}}</label>
{{#if this.editingModel.user}}
<a
class="avatar"
href={{this.editingModel.user.path}}
data-user-card={{this.editingModel.user.username}}
>
{{Avatar this.editingModel.user.avatar_template "small"}}
</a>
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{this.editingModel.user.username}}
</LinkTo>
{{else}}
<DButton
@action={{this.createUser}}
class="ai-persona-editor__create-user"
>
{{I18n.t "discourse_ai.ai_persona.create_user"}}
</DButton>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.create_user_help"}}
/>
{{/if}}
</div>
{{/unless}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
<AiCommandSelector
class="ai-persona-editor__commands"
@value={{this.editingModel.commands}}
@disabled={{this.editingModel.system}}
@commands={{@personas.resultSetMeta.commands}}
/>
</div>
{{#unless this.editingModel.system}}
<AiPersonaCommandOptions
@persona={{this.editingModel}}
@commands={{this.editingModel.commands}}
@allCommands={{@personas.resultSetMeta.commands}}
/>
{{/unless}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
<GroupChooser
@value={{this.editingModel.allowed_group_ids}}
@content={{this.allGroups}}
@onChange={{this.updateAllowedGroups}}
/>
</div>
<div class="control-group">
<label for="ai-persona-editor__system_prompt">{{I18n.t
"discourse_ai.ai_persona.system_prompt"
}}</label>
<Textarea
class="ai-persona-editor__system_prompt"
@value={{this.editingModel.system_prompt}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.max_context_posts"}}</label>
<Input
@type="number"
lang="en"
class="ai-persona-editor__max_context_posts"
@value={{this.editingModel.max_context_posts}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.max_context_posts_help"}}
/>
</div>
{{#if @model.vision_enabled}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.vision_max_pixels"}}</label>
<ComboBox
@value={{this.maxPixelsValue}}
@content={{this.maxPixelValues}}
@onChange={{this.onChangeMaxPixels}}
/>
</div>
{{/if}}
<div class="control-group">
{{#if this.showTemperature}}
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>
<Input
@type="number"
class="ai-persona-editor__temperature"
step="any"
lang="en"
@value={{this.editingModel.temperature}}
disabled={{this.editingModel.system}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.temperature_help"}}
/>
{{/if}}
{{#if this.showTopP}}
<label>{{I18n.t "discourse_ai.ai_persona.top_p"}}</label>
<Input
@type="number"
step="any"
lang="en"
class="ai-persona-editor__top_p"
@value={{this.editingModel.top_p}}
disabled={{this.editingModel.system}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.ai_persona.top_p_help"}}
/>
{{/if}}
</div>
{{#if this.siteSettings.ai_embeddings_enabled}}
<div class="control-group">
<PersonaRagUploader
@persona={{this.editingModel}}
@updateUploads={{this.updateUploads}}
@onRemove={{this.removeUpload}}
/>
{{#if this.editingModel.rag_uploads}}
<a
href="#"
class="ai-persona-editor__indexing-options"
{{on "click" this.toggleIndexingOptions}}
>{{this.indexingOptionsText}}</a>
{{/if}}
</div>
{{#if this.showIndexingOptions}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.rag_chunk_tokens"}}</label>
<Input
@type="number"
step="any"
lang="en"
class="ai-persona-editor__rag_chunk_tokens"
@value={{this.editingModel.rag_chunk_tokens}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t
"discourse_ai.ai_persona.rag_chunk_tokens_help"
}}
/>
</div>
<div class="control-group">
<label>{{I18n.t
"discourse_ai.ai_persona.rag_chunk_overlap_tokens"
}}</label>
<Input
@type="number"
step="any"
lang="en"
class="ai-persona-editor__rag_chunk_overlap_tokens"
@value={{this.editingModel.rag_chunk_overlap_tokens}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t
"discourse_ai.ai_persona.rag_chunk_overlap_tokens_help"
}}
/>
</div>
<div class="control-group">
<label>{{I18n.t
"discourse_ai.ai_persona.rag_conversation_chunks"
}}</label>
<Input
@type="number"
step="any"
lang="en"
class="ai-persona-editor__rag_conversation_chunks"
@value={{this.editingModel.rag_conversation_chunks}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t
"discourse_ai.ai_persona.rag_conversation_chunks_help"
}}
/>
</div>
<div class="control-group">
<label>{{I18n.t
"discourse_ai.ai_persona.question_consolidator_llm"
}}</label>
<AiLlmSelector
class="ai-persona-editor__llms"
@value={{this.mappedQuestionConsolidatorLlm}}
@llms={{@personas.resultSetMeta.llms}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t
"discourse_ai.ai_persona.question_consolidator_llm_help"
}}
/>
</div>
{{/if}}
{{/if}}
<div class="control-group ai-persona-editor__action_panel">
<DButton
class="btn-primary ai-persona-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>{{I18n.t "discourse_ai.ai_persona.save"}}</DButton>
{{#if this.showDelete}}
<DButton
@action={{this.delete}}
class="btn-danger ai-persona-editor__delete"
>
{{I18n.t "discourse_ai.ai_persona.delete"}}
</DButton>
{{/if}}
</div>
</form>
</template>
}