691 lines
20 KiB
Plaintext
691 lines
20 KiB
Plaintext
import Component from "@glimmer/component";
|
|
import { cached, tracked } from "@glimmer/tracking";
|
|
import { fn } from "@ember/helper";
|
|
import { action } from "@ember/object";
|
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
|
import { LinkTo } from "@ember/routing";
|
|
import { later } from "@ember/runloop";
|
|
import { service } from "@ember/service";
|
|
import { gt, or } from "truth-helpers";
|
|
import BackButton from "discourse/components/back-button";
|
|
import Form from "discourse/components/form";
|
|
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 GroupChooser from "select-kit/components/group-chooser";
|
|
import AiLlmSelector from "./ai-llm-selector";
|
|
import AiPersonaToolOptions from "./ai-persona-tool-options";
|
|
import AiToolSelector from "./ai-tool-selector";
|
|
import RagOptionsFk from "./rag-options-fk";
|
|
import RagUploader from "./rag-uploader";
|
|
|
|
export default class PersonaEditor extends Component {
|
|
@service router;
|
|
@service store;
|
|
@service dialog;
|
|
@service toasts;
|
|
@service siteSettings;
|
|
|
|
@tracked allGroups = [];
|
|
@tracked isSaving = false;
|
|
|
|
dirtyFormData = null;
|
|
|
|
@cached
|
|
get formData() {
|
|
// This is to recover a dirty state after persisting a single form field.
|
|
// It's meant to be consumed only once.
|
|
if (this.dirtyFormData) {
|
|
const data = this.dirtyFormData;
|
|
this.dirtyFormData = null;
|
|
return data;
|
|
} else {
|
|
const data = this.args.model.toPOJO();
|
|
|
|
if (data.tools) {
|
|
data.toolOptions = this.mapToolOptions(data.toolOptions, data.tools);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
}
|
|
|
|
get chatPluginEnabled() {
|
|
return this.siteSettings.chat_enabled;
|
|
}
|
|
|
|
get allTools() {
|
|
return this.args.personas.resultSetMeta.tools;
|
|
}
|
|
|
|
get maxPixelValues() {
|
|
const l = (key) =>
|
|
i18n(`discourse_ai.ai_persona.vision_max_pixel_sizes.${key}`);
|
|
return [
|
|
{ name: l("low"), id: 65536 },
|
|
{ name: l("medium"), id: 262144 },
|
|
{ name: l("high"), id: 1048576 },
|
|
];
|
|
}
|
|
|
|
get forcedToolStrategies() {
|
|
const content = [
|
|
{
|
|
id: -1,
|
|
name: i18n("discourse_ai.ai_persona.tool_strategies.all"),
|
|
},
|
|
];
|
|
|
|
[1, 2, 5].forEach((i) => {
|
|
content.push({
|
|
id: i,
|
|
name: i18n("discourse_ai.ai_persona.tool_strategies.replies", {
|
|
count: i,
|
|
}),
|
|
});
|
|
});
|
|
|
|
return content;
|
|
}
|
|
|
|
@action
|
|
async updateAllGroups() {
|
|
const groups = await Group.findAll({ include_everyone: true });
|
|
|
|
// Backwards-compatibility code. TODO(roman): Remove 01-09-2025
|
|
const hasEveryoneGroup = groups.find((g) => g.id === 0);
|
|
if (!hasEveryoneGroup) {
|
|
const everyoneGroupName = "everyone";
|
|
groups.push({ id: 0, name: everyoneGroupName });
|
|
}
|
|
|
|
this.allGroups = groups;
|
|
}
|
|
|
|
@action
|
|
async save(data) {
|
|
const isNew = this.args.model.isNew;
|
|
this.isSaving = true;
|
|
|
|
try {
|
|
const personaToSave = Object.assign(
|
|
this.args.model,
|
|
this.args.model.fromPOJO(data)
|
|
);
|
|
|
|
await personaToSave.save();
|
|
this.#sortPersonas();
|
|
|
|
if (isNew && this.args.model.rag_uploads.length === 0) {
|
|
this.args.personas.addObject(personaToSave);
|
|
this.router.transitionTo(
|
|
"adminPlugins.show.discourse-ai-personas.edit",
|
|
personaToSave
|
|
);
|
|
} else {
|
|
this.toasts.success({
|
|
data: { message: i18n("discourse_ai.ai_persona.saved") },
|
|
duration: 2000,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
popupAjaxError(e);
|
|
} finally {
|
|
later(() => {
|
|
this.isSaving = false;
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
get adminUser() {
|
|
// Work around user not being extensible.
|
|
const userClone = Object.assign({}, this.args.model?.user);
|
|
|
|
return AdminUser.create(userClone);
|
|
}
|
|
|
|
@action
|
|
delete() {
|
|
return this.dialog.confirm({
|
|
message: i18n("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-personas.index"
|
|
);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
async toggleEnabled(dirtyData, value, { set }) {
|
|
set("enabled", value);
|
|
await this.persistField(dirtyData, "enabled", value);
|
|
}
|
|
|
|
@action
|
|
async togglePriority(dirtyData, value, { set }) {
|
|
set("priority", value);
|
|
await this.persistField(dirtyData, "priority", value, true);
|
|
}
|
|
|
|
@action
|
|
async createUser(form) {
|
|
try {
|
|
let user = await this.args.model.createUser();
|
|
form.set("user", user);
|
|
form.set("user_id", user.id);
|
|
} catch (e) {
|
|
popupAjaxError(e);
|
|
}
|
|
}
|
|
|
|
@action
|
|
updateUploads(form, newUploads) {
|
|
form.set("rag_uploads", newUploads);
|
|
}
|
|
|
|
@action
|
|
async removeUpload(form, dirtyData, currentUploads, upload) {
|
|
const updatedUploads = currentUploads.filter(
|
|
(file) => file.id !== upload.id
|
|
);
|
|
|
|
form.set("rag_uploads", updatedUploads);
|
|
|
|
if (!this.args.model.isNew) {
|
|
await this.persistField(dirtyData, "rag_uploads", updatedUploads);
|
|
}
|
|
}
|
|
|
|
@action
|
|
updateToolNames(form, currentData, updatedTools) {
|
|
const removedTools =
|
|
currentData?.tools?.filter((ct) => !updatedTools.includes(ct)) || [];
|
|
const updatedOptions = this.mapToolOptions(
|
|
currentData.toolOptions,
|
|
updatedTools
|
|
);
|
|
|
|
form.setProperties({
|
|
tools: updatedTools,
|
|
toolOptions: updatedOptions,
|
|
});
|
|
|
|
if (currentData.forcedTools?.length > 0) {
|
|
const updatedForcedTools = currentData.forcedTools.filter(
|
|
(fct) => !removedTools.includes(fct)
|
|
);
|
|
form.set("forcedTools", updatedForcedTools);
|
|
}
|
|
}
|
|
|
|
@action
|
|
availableForcedTools(tools) {
|
|
return this.allTools.filter((tool) => tools.includes(tool.id));
|
|
}
|
|
|
|
mapToolOptions(currentOptions, toolNames) {
|
|
const updatedOptions = Object.assign({}, currentOptions);
|
|
|
|
toolNames.forEach((toolId) => {
|
|
const tool = this.allTools.findBy("id", toolId);
|
|
const toolOptions = tool?.options;
|
|
|
|
if (!toolOptions || updatedOptions[toolId]) {
|
|
return;
|
|
}
|
|
|
|
const mappedOptions = {};
|
|
Object.keys(toolOptions).forEach((key) => {
|
|
mappedOptions[key] = null;
|
|
});
|
|
|
|
updatedOptions[toolId] = mappedOptions;
|
|
});
|
|
|
|
return updatedOptions;
|
|
}
|
|
|
|
async persistField(dirtyData, field, newValue, sortPersonas) {
|
|
if (!this.args.model.isNew) {
|
|
const updatedDirtyData = Object.assign({}, dirtyData);
|
|
updatedDirtyData[field] = newValue;
|
|
|
|
try {
|
|
const args = {};
|
|
args[field] = newValue;
|
|
|
|
this.dirtyFormData = updatedDirtyData;
|
|
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-personas"
|
|
@label="discourse_ai.ai_persona.back"
|
|
/>
|
|
<div class="ai-persona-editor" {{didInsert this.updateAllGroups @model.id}}>
|
|
<Form @onSubmit={{this.save}} @data={{this.formData}} as |form data|>
|
|
<form.Field
|
|
@name="name"
|
|
@title={{i18n "discourse_ai.ai_persona.name"}}
|
|
@validation="required|length:1,100"
|
|
@disabled={{data.system}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Input />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="description"
|
|
@title={{i18n "discourse_ai.ai_persona.description"}}
|
|
@validation="required|length:1,100"
|
|
@disabled={{data.system}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Textarea />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="system_prompt"
|
|
@title={{i18n "discourse_ai.ai_persona.system_prompt"}}
|
|
@validation="required|length:1,100000"
|
|
@disabled={{data.system}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Textarea />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="default_llm_id"
|
|
@title={{i18n "discourse_ai.ai_persona.default_llm"}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.default_llm_help"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Custom>
|
|
<AiLlmSelector
|
|
@value={{field.value}}
|
|
@llms={{@personas.resultSetMeta.llms}}
|
|
@onChange={{field.set}}
|
|
@class="ai-persona-editor__llms"
|
|
/>
|
|
</field.Custom>
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="allowed_group_ids"
|
|
@title={{i18n "discourse_ai.ai_persona.allowed_groups"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Custom>
|
|
<GroupChooser
|
|
@value={{data.allowed_group_ids}}
|
|
@content={{this.allGroups}}
|
|
@onChange={{field.set}}
|
|
/>
|
|
</field.Custom>
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="vision_enabled"
|
|
@title={{i18n "discourse_ai.ai_persona.vision_enabled"}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.vision_enabled_help"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
|
|
{{#if data.vision_enabled}}
|
|
<form.Field
|
|
@name="vision_max_pixels"
|
|
@title={{i18n "discourse_ai.ai_persona.vision_max_pixels"}}
|
|
@onSet={{this.onChangeMaxPixels}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Select @includeNone={{false}} as |select|>
|
|
{{#each this.maxPixelValues as |pixelValue|}}
|
|
<select.Option
|
|
@value={{pixelValue.id}}
|
|
>{{pixelValue.name}}</select.Option>
|
|
{{/each}}
|
|
</field.Select>
|
|
</form.Field>
|
|
{{/if}}
|
|
|
|
<form.Field
|
|
@name="max_context_posts"
|
|
@title={{i18n "discourse_ai.ai_persona.max_context_posts"}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.max_context_posts_help"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Input @type="number" lang="en" />
|
|
</form.Field>
|
|
|
|
{{#unless data.system}}
|
|
<form.Field
|
|
@name="temperature"
|
|
@title={{i18n "discourse_ai.ai_persona.temperature"}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.temperature_help"}}
|
|
@disabled={{data.system}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Input @type="number" step="any" lang="en" />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="top_p"
|
|
@title={{i18n "discourse_ai.ai_persona.top_p"}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.top_p_help"}}
|
|
@disabled={{data.system}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Input @type="number" step="any" lang="en" />
|
|
</form.Field>
|
|
{{/unless}}
|
|
|
|
<form.Section @title={{i18n "discourse_ai.ai_persona.ai_tools"}}>
|
|
<form.Field
|
|
@name="tools"
|
|
@title={{i18n "discourse_ai.ai_persona.tools"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Custom>
|
|
<AiToolSelector
|
|
@value={{field.value}}
|
|
@disabled={{data.system}}
|
|
@onChange={{fn this.updateToolNames form data}}
|
|
@content={{@personas.resultSetMeta.tools}}
|
|
/>
|
|
</field.Custom>
|
|
</form.Field>
|
|
|
|
{{#if (gt data.tools.length 0)}}
|
|
<form.Field
|
|
@name="forcedTools"
|
|
@title={{i18n "discourse_ai.ai_persona.forced_tools"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Custom>
|
|
<AiToolSelector
|
|
@value={{field.value}}
|
|
@disabled={{data.system}}
|
|
@onChange={{field.set}}
|
|
@content={{this.availableForcedTools data.tools}}
|
|
/>
|
|
</field.Custom>
|
|
</form.Field>
|
|
{{/if}}
|
|
|
|
{{#if (gt data.forcedTools.length 0)}}
|
|
<form.Field
|
|
@name="forced_tool_count"
|
|
@title={{i18n "discourse_ai.ai_persona.forced_tool_strategy"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Select @includeNone={{false}} as |select|>
|
|
{{#each this.forcedToolStrategies as |fts|}}
|
|
<select.Option @value={{fts.id}}>{{fts.name}}</select.Option>
|
|
{{/each}}
|
|
</field.Select>
|
|
</form.Field>
|
|
{{/if}}
|
|
|
|
{{#if (gt data.tools.length 0)}}
|
|
<form.Field
|
|
@name="tool_details"
|
|
@title={{i18n "discourse_ai.ai_persona.tool_details"}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.tool_details_help"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
|
|
<AiPersonaToolOptions
|
|
@form={{form}}
|
|
@data={{data}}
|
|
@llms={{@personas.resultSetMeta.llms}}
|
|
@allTools={{@personas.resultSetMeta.tools}}
|
|
/>
|
|
{{/if}}
|
|
</form.Section>
|
|
|
|
{{#if this.siteSettings.ai_embeddings_enabled}}
|
|
<form.Section @title={{i18n "discourse_ai.rag.title"}}>
|
|
<form.Field
|
|
@name="rag_uploads"
|
|
@title={{i18n "discourse_ai.rag.uploads.title"}}
|
|
as |field|
|
|
>
|
|
<field.Custom>
|
|
<RagUploader
|
|
@target={{data}}
|
|
@targetName="AiPersona"
|
|
@updateUploads={{fn this.updateUploads form}}
|
|
@onRemove={{fn this.removeUpload form data field.value}}
|
|
@allowImages={{@personas.resultSetMeta.settings.rag_images_enabled}}
|
|
/>
|
|
</field.Custom>
|
|
</form.Field>
|
|
|
|
<RagOptionsFk
|
|
@form={{form}}
|
|
@data={{data}}
|
|
@llms={{@personas.resultSetMeta.llms}}
|
|
@allowImages={{@personas.resultSetMeta.settings.rag_images_enabled}}
|
|
>
|
|
<form.Field
|
|
@name="rag_conversation_chunks"
|
|
@title={{i18n
|
|
"discourse_ai.ai_persona.rag_conversation_chunks"
|
|
}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.ai_persona.rag_conversation_chunks_help"
|
|
}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Input @type="number" step="any" lang="en" />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="question_consolidator_llm_id"
|
|
@title={{i18n
|
|
"discourse_ai.ai_persona.question_consolidator_llm"
|
|
}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.ai_persona.question_consolidator_llm_help"
|
|
}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Custom>
|
|
<AiLlmSelector
|
|
@value={{field.value}}
|
|
@llms={{@personas.resultSetMeta.llms}}
|
|
@onChange={{field.set}}
|
|
@class="ai-persona-editor__llms"
|
|
/>
|
|
</field.Custom>
|
|
</form.Field>
|
|
</RagOptionsFk>
|
|
</form.Section>
|
|
{{/if}}
|
|
|
|
<form.Section @title={{i18n "discourse_ai.ai_persona.ai_bot.title"}}>
|
|
<form.Field
|
|
@name="enabled"
|
|
@title={{i18n "discourse_ai.ai_persona.enabled"}}
|
|
@onSet={{fn this.toggleEnabled data}}
|
|
as |field|
|
|
>
|
|
<field.Toggle />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="priority"
|
|
@title={{i18n "discourse_ai.ai_persona.priority"}}
|
|
@onSet={{fn this.togglePriority data}}
|
|
@tooltip={{i18n "discourse_ai.ai_persona.priority_help"}}
|
|
as |field|
|
|
>
|
|
<field.Toggle />
|
|
</form.Field>
|
|
|
|
{{#if @model.isNew}}
|
|
<div>{{i18n "discourse_ai.ai_persona.ai_bot.save_first"}}</div>
|
|
{{else}}
|
|
{{#if data.default_llm_id}}
|
|
<form.Field
|
|
@name="force_default_llm"
|
|
@title={{i18n "discourse_ai.ai_persona.force_default_llm"}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
{{/if}}
|
|
|
|
<form.Container
|
|
@title={{i18n "discourse_ai.ai_persona.user"}}
|
|
@tooltip={{unless
|
|
data.user
|
|
(i18n "discourse_ai.ai_persona.create_user_help")
|
|
}}
|
|
class="ai-persona-editor__ai_bot_user"
|
|
>
|
|
{{#if data.user}}
|
|
<a
|
|
class="avatar"
|
|
href={{data.user.path}}
|
|
data-user-card={{data.user.username}}
|
|
>
|
|
{{Avatar data.user.avatar_template "small"}}
|
|
</a>
|
|
<LinkTo @route="adminUser" @model={{this.adminUser}}>
|
|
{{data.user.username}}
|
|
</LinkTo>
|
|
{{else}}
|
|
<form.Button
|
|
@action={{fn this.createUser form}}
|
|
@label="discourse_ai.ai_persona.create_user"
|
|
class="ai-persona-editor__create-user"
|
|
/>
|
|
{{/if}}
|
|
</form.Container>
|
|
|
|
{{#if data.user}}
|
|
<form.Field
|
|
@name="allow_personal_messages"
|
|
@title={{i18n
|
|
"discourse_ai.ai_persona.allow_personal_messages"
|
|
}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.ai_persona.allow_personal_messages_help"
|
|
}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="allow_topic_mentions"
|
|
@title={{i18n "discourse_ai.ai_persona.allow_topic_mentions"}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.ai_persona.allow_topic_mentions_help"
|
|
}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
|
|
{{#if this.chatPluginEnabled}}
|
|
<form.Field
|
|
@name="allow_chat_direct_messages"
|
|
@title={{i18n
|
|
"discourse_ai.ai_persona.allow_chat_direct_messages"
|
|
}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.ai_persona.allow_chat_direct_messages_help"
|
|
}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
|
|
<form.Field
|
|
@name="allow_chat_channel_mentions"
|
|
@title={{i18n
|
|
"discourse_ai.ai_persona.allow_chat_channel_mentions"
|
|
}}
|
|
@tooltip={{i18n
|
|
"discourse_ai.ai_persona.allow_chat_channel_mentions_help"
|
|
}}
|
|
@format="large"
|
|
as |field|
|
|
>
|
|
<field.Checkbox />
|
|
</form.Field>
|
|
{{/if}}
|
|
{{/if}}
|
|
{{/if}}
|
|
</form.Section>
|
|
|
|
<form.Actions>
|
|
<form.Submit />
|
|
|
|
{{#unless (or @model.isNew @model.system)}}
|
|
<form.Button
|
|
@action={{this.delete}}
|
|
@label="discourse_ai.ai_persona.delete"
|
|
class="btn-danger"
|
|
/>
|
|
{{/unless}}
|
|
</form.Actions>
|
|
</Form>
|
|
</div>
|
|
</template>
|
|
}
|