REFACTOR: Migrate Personas' form to FormKit (#1178)

* REFACTOR: Migrate Personas' form to FormKit

We re-arranged fields into sections so we can better differentiate which options are specific to the AI bot.

* few form-kit improvements

https://github.com/discourse/discourse/pull/31934

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Roman Rizzi 2025-03-21 14:46:33 -03:00 committed by GitHub
parent 0e2dd7378f
commit 2a8be6e2d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 788 additions and 930 deletions

View File

@ -1,4 +1,3 @@
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
@ -63,40 +62,7 @@ const SYSTEM_ATTRIBUTES = [
"allow_chat_direct_messages", "allow_chat_direct_messages",
]; ];
class ToolOption {
@tracked value = null;
}
export default class AiPersona extends RestModel { export default class AiPersona extends RestModel {
// this code is here to convert the wire schema to easier to work with object
// on the wire we pass in/out tools as an Array.
// [[ToolName, {option1: value, option2: value}, force], ToolName2, ToolName3]
// So we rework this into a "tools" property and nested toolOptions
init(properties) {
this.forcedTools = [];
if (properties.tools) {
properties.tools = properties.tools.map((tool) => {
if (typeof tool === "string") {
return tool;
} else {
let [toolId, options, force] = tool;
for (let optionId in options) {
if (!options.hasOwnProperty(optionId)) {
continue;
}
this.getToolOption(toolId, optionId).value = options[optionId];
}
if (force) {
this.forcedTools.push(toolId);
}
return toolId;
}
});
}
super.init(properties);
this.tools = properties.tools;
}
async createUser() { async createUser() {
const result = await ajax( const result = await ajax(
`/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`, `/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`,
@ -109,63 +75,79 @@ export default class AiPersona extends RestModel {
return this.user; return this.user;
} }
getToolOption(toolId, optionId) { flattenedToolStructure(data) {
this.toolOptions ||= {}; return data.tools.map((tName) => {
this.toolOptions[toolId] ||= {}; return [tName, data.toolOptions[tName], data.forcedTools.includes(tName)];
return (this.toolOptions[toolId][optionId] ||= new ToolOption()); });
} }
populateToolOptions(attrs) { // this code is here to convert the wire schema to easier to work with object
if (!attrs.tools) { // on the wire we pass in/out tools as an Array.
return; // [[ToolName, {option1: value, option2: value}, force], ToolName2, ToolName3]
} // We split it into tools, options and a list of forced ones.
let toolsWithOptions = []; populateTools(attrs) {
attrs.tools.forEach((toolId) => { const forcedTools = [];
if (typeof toolId !== "string") { const toolOptions = {};
toolId = toolId[0];
}
let force = this.forcedTools.includes(toolId); const flatTools = attrs.tools?.map((tool) => {
if (this.toolOptions && this.toolOptions[toolId]) { if (typeof tool === "string") {
let options = this.toolOptions[toolId]; return tool;
let optionsWithValues = {}; } else {
for (let optionId in options) { let [toolId, options, force] = tool;
const mappedOptions = {};
for (const optionId in options) {
if (!options.hasOwnProperty(optionId)) { if (!options.hasOwnProperty(optionId)) {
continue; continue;
} }
let option = options[optionId];
optionsWithValues[optionId] = option.value; mappedOptions[optionId] = options[optionId];
} }
toolsWithOptions.push([toolId, optionsWithValues, force]);
} else { if (Object.keys(mappedOptions).length > 0) {
toolsWithOptions.push([toolId, {}, force]); toolOptions[toolId] = mappedOptions;
}
if (force) {
forcedTools.push(toolId);
}
return toolId;
} }
}); });
attrs.tools = toolsWithOptions;
attrs.tools = flatTools;
attrs.forcedTools = forcedTools;
attrs.toolOptions = toolOptions;
} }
updateProperties() { updateProperties() {
let attrs = this.system const attrs = this.system
? this.getProperties(SYSTEM_ATTRIBUTES) ? this.getProperties(SYSTEM_ATTRIBUTES)
: this.getProperties(CREATE_ATTRIBUTES); : this.getProperties(CREATE_ATTRIBUTES);
attrs.id = this.id; attrs.id = this.id;
this.populateToolOptions(attrs);
return attrs; return attrs;
} }
createProperties() { createProperties() {
let attrs = this.getProperties(CREATE_ATTRIBUTES); return this.getProperties(CREATE_ATTRIBUTES);
this.populateToolOptions(attrs);
return attrs;
} }
workingCopy() { fromPOJO(data) {
let attrs = this.getProperties(CREATE_ATTRIBUTES); const dataClone = JSON.parse(JSON.stringify(data));
this.populateToolOptions(attrs);
const persona = AiPersona.create(dataClone);
persona.tools = this.flattenedToolStructure(dataClone);
const persona = AiPersona.create(attrs);
persona.forcedTools = (this.forcedTools || []).slice();
persona.forced_tool_count = this.forced_tool_count || -1;
return persona; return persona;
} }
toPOJO() {
const attrs = this.getProperties(CREATE_ATTRIBUTES);
this.populateTools(attrs);
attrs.forced_tool_count = this.forced_tool_count || -1;
return attrs;
}
} }

View File

@ -1,29 +0,0 @@
import { computed } from "@ember/object";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
export default ComboBox.extend({
content: computed(function () {
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;
}),
selectKitOptions: {
filterable: false,
},
});

View File

@ -0,0 +1,17 @@
import { hash } from "@ember/helper";
import ComboBox from "select-kit/components/combo-box";
const AiLlmSelector = <template>
<ComboBox
@value={{@value}}
@content={{@llms}}
@onChange={{@onChange}}
@options={{hash
filterable=true
none="discourse_ai.ai_persona.no_llm_selected"
}}
class={{@class}}
/>
</template>;
export default AiLlmSelector;

View File

@ -1,27 +0,0 @@
import { computed } from "@ember/object";
import { observes } from "@ember-decorators/object";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import { selectKitOptions } from "select-kit/components/select-kit";
@selectKitOptions({
filterable: true,
})
export default class AiLlmSelector extends ComboBox {
@observes("attrs.disabled")
_modelDisabledChanged() {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}
@computed
get content() {
const blankName =
this.attrs.blankName || i18n("discourse_ai.ai_persona.no_llm_selected");
return [
{
id: "blank",
name: blankName,
},
].concat(this.llms);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,91 +0,0 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n";
import AiLlmSelector from "./ai-llm-selector";
export default class AiPersonaToolOptionEditor extends Component {
get isBoolean() {
return this.args.option.type === "boolean";
}
get isEnum() {
return this.args.option.type === "enum";
}
get isLlm() {
return this.args.option.type === "llm";
}
get selectedValue() {
return this.args.option.value.value === "true";
}
get selectedLlm() {
if (this.args.option.value.value) {
return `custom:${this.args.option.value.value}`;
} else {
return "blank";
}
}
set selectedLlm(value) {
if (value === "blank") {
this.args.option.value.value = null;
} else {
this.args.option.value.value = value.replace("custom:", "");
}
}
@action
onCheckboxChange(event) {
this.args.option.value.value = event.target.checked ? "true" : "false";
}
@action
onSelectOption(event) {
this.args.option.value.value = event.target.value;
}
<template>
<div class="control-group ai-persona-tool-option-editor">
<label>
{{@option.name}}
</label>
<div class="">
{{#if this.isEnum}}
<select name="input" {{on "change" this.onSelectOption}}>
{{#each @option.values as |value|}}
<option value={{value}} selected={{eq value @option.value.value}}>
{{value}}
</option>
{{/each}}
</select>
{{else if this.isLlm}}
<AiLlmSelector
class="ai-persona-tool-option-editor__llms"
@value={{this.selectedLlm}}
@llms={{@llms}}
@blankName={{i18n "discourse_ai.ai_persona.use_parent_llm"}}
/>
{{else if this.isBoolean}}
<input
type="checkbox"
checked={{this.selectedValue}}
{{on "click" this.onCheckboxChange}}
/>
{{@option.description}}
{{else}}
<Input @value={{@option.value.value}} />
{{/if}}
</div>
{{#unless this.isBoolean}}
<div class="ai-persona-tool-option-editor__instructions">
{{@option.description}}
</div>
{{/unless}}
</div>
</template>
}

View File

@ -1,82 +1,95 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { action, get } from "@ember/object";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import AiPersonaToolOptionEditor from "./ai-persona-tool-option-editor"; import AiLlmSelector from "./ai-llm-selector";
export default class AiPersonaToolOptions extends Component { export default class AiPersonaToolOptions extends Component {
get showToolOptions() { get showToolOptions() {
const allTools = this.args.allTools; const allTools = this.args.allTools;
if (!allTools) { if (!allTools || !this.args.data.tools) {
return false; return false;
} }
return this.args.data?.tools.any(
return this.toolNames.any((tool) => allTools.findBy("id", tool)?.options); (tool) => allTools.findBy("id", tool)?.options
);
} }
get toolNames() { get toolsMetadata() {
if (!this.args.tools) { const metatada = {};
return [];
} this.args.allTools.map((t) => {
return this.args.tools.map((tool) => { metatada[t.id] = {
if (typeof tool === "string") { name: t.name,
return tool; ...t?.options,
} else { };
return tool[0];
}
}); });
return metatada;
} }
get toolOptions() { @action
if (!this.args.tools) { formObjectKeys(toolOptions) {
return []; return Object.keys(toolOptions);
}
const allTools = this.args.allTools;
if (!allTools) {
return [];
}
const options = [];
this.toolNames.forEach((toolId) => {
const tool = allTools.findBy("id", toolId);
const toolName = tool?.name;
const toolOptions = tool?.options;
if (toolOptions) {
const mappedOptions = Object.keys(toolOptions).map((key) => {
const value = this.args.persona.getToolOption(toolId, key);
return Object.assign({}, toolOptions[key], { id: key, value });
});
options.push({ toolName, options: mappedOptions });
}
});
return options;
} }
<template> <template>
{{#if this.showToolOptions}} {{#if this.showToolOptions}}
<div class="control-group"> <@form.Container
<label>{{i18n "discourse_ai.ai_persona.tool_options"}}</label> @title={{i18n "discourse_ai.ai_persona.tool_options"}}
<div> @direction="column"
{{#each this.toolOptions as |toolOption|}} @format="full"
>
<@form.Object
@name="toolOptions"
@title={{i18n "discourse_ai.ai_persona.tool_options"}}
as |toolObj optsPerTool|
>
{{#each (this.formObjectKeys optsPerTool) as |toolId|}}
<div class="ai-persona-editor__tool-options"> <div class="ai-persona-editor__tool-options">
<div class="ai-persona-editor__tool-options-name"> {{#let (get this.toolsMetadata toolId) as |toolMeta|}}
{{toolOption.toolName}} <div class="ai-persona-editor__tool-options-name">
</div> {{toolMeta.name}}
<div class="ai-persona-editor__tool-option-options"> </div>
{{#each toolOption.options as |option|}} <toolObj.Object @name={{toolId}} as |optionsObj optionData|>
<AiPersonaToolOptionEditor {{#each (this.formObjectKeys optionData) as |optionName|}}
@option={{option}} {{#let (get toolMeta optionName) as |optionMeta|}}
@llms={{@llms}} <optionsObj.Field
/> @name={{optionName}}
{{/each}} @title={{optionMeta.name}}
</div> @helpText={{optionMeta.description}}
@format="full"
as |field|
>
{{#if (eq optionMeta.type "enum")}}
<field.Select @includeNone={{false}} as |select|>
{{#each optionsObj.values as |v|}}
<select.Option @value={{v}}>{{v}}</select.Option>
{{/each}}
</field.Select>
{{else if (eq optionMeta.type "llm")}}
<field.Custom>
<AiLlmSelector
@value={{field.value}}
@llms={{@llms}}
@onChange={{field.set}}
@class="ai-persona-tool-option-editor__llms"
/>
</field.Custom>
{{else if (eq optionMeta.type "boolean")}}
<field.Checkbox />
{{else}}
<field.Input />
{{/if}}
</optionsObj.Field>
{{/let}}
{{/each}}
</toolObj.Object>
{{/let}}
</div> </div>
{{/each}} {{/each}}
</div> </@form.Object>
</div> </@form.Container>
{{/if}} {{/if}}
</template> </template>
} }

View File

@ -0,0 +1,13 @@
import { hash } from "@ember/helper";
import MultiSelect from "select-kit/components/multi-select";
const AiToolSelector = <template>
<MultiSelect
@value={{@value}}
@onChange={{@onChange}}
@content={{@content}}
@options={{hash filterable=true allowAny=false disabled=@disabled}}
/>
</template>;
export default AiToolSelector;

View File

@ -1,18 +0,0 @@
import { readOnly } from "@ember/object/computed";
import { observes } from "@ember-decorators/object";
import MultiSelectComponent from "select-kit/components/multi-select";
import { selectKitOptions } from "select-kit/components/select-kit";
@selectKitOptions({
filterable: true,
})
export default class AiToolSelector extends MultiSelectComponent {
@readOnly("tools") content;
value = "";
@observes("attrs.disabled")
_modelDisabledChanged() {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}
}

View File

@ -0,0 +1,81 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { i18n } from "discourse-i18n";
import AiLlmSelector from "./ai-llm-selector";
export default class RagOptionsFk extends Component {
@tracked showIndexingOptions = false;
@action
toggleIndexingOptions(event) {
this.showIndexingOptions = !this.showIndexingOptions;
event.preventDefault();
event.stopPropagation();
}
get indexingOptionsText() {
return this.showIndexingOptions
? i18n("discourse_ai.rag.options.hide_indexing_options")
: i18n("discourse_ai.rag.options.show_indexing_options");
}
get visionLlms() {
return this.args.llms.filter((llm) => llm.vision_enabled);
}
<template>
{{#if @data.rag_uploads}}
<a
href="#"
class="rag-options__indexing-options"
{{on "click" this.toggleIndexingOptions}}
>{{this.indexingOptionsText}}</a>
{{/if}}
{{#if this.showIndexingOptions}}
<@form.Field
@name="rag_chunk_tokens"
@title={{i18n "discourse_ai.rag.options.rag_chunk_tokens"}}
@tooltip={{i18n "discourse_ai.rag.options.rag_chunk_tokens_help"}}
@format="large"
as |field|
>
<field.Input @type="number" step="any" lang="en" />
</@form.Field>
<@form.Field
@name="rag_chunk_overlap_tokens"
@title={{i18n "discourse_ai.rag.options.rag_chunk_tokens"}}
@tooltip={{i18n
"discourse_ai.rag.options.rag_chunk_overlap_tokens_help"
}}
@format="large"
as |field|
>
<field.Input @type="number" step="any" lang="en" />
</@form.Field>
{{#if @allowImages}}
<@form.Field
@name="rag_llm_model_id"
@title={{i18n "discourse_ai.rag.options.rag_llm_model"}}
@tooltip={{i18n "discourse_ai.rag.options.rag_llm_model_help"}}
@format="large"
as |field|
>
<field.Custom>
<AiLlmSelector
@value={{field.value}}
@llms={{this.visionLlms}}
@onChange={{field.set}}
@class="ai-persona-editor__llms"
/>
</field.Custom>
</@form.Field>
{{/if}}
{{yield}}
{{/if}}
</template>
}

View File

@ -54,11 +54,10 @@ export default class RagUploader extends Component {
this.uppyUpload.cancelAllUploads(); this.uppyUpload.cancelAllUploads();
} }
this.ragUploads = this.target?.rag_uploads || []; this.ragUploads = this.target?.rag_uploads?.slice() || [];
this.filteredUploads = this.ragUploads; this.filteredUploads = this.ragUploads;
const targetName = this.target?.constructor?.name; const targetName = this.targetName || this.target?.constructor?.name;
if (this.ragUploads?.length && this.target?.id) { if (this.ragUploads?.length && this.target?.id) {
ajax( ajax(
`/admin/plugins/discourse-ai/rag-document-fragments/files/status.json?target_type=${targetName}&target_id=${this.target.id}` `/admin/plugins/discourse-ai/rag-document-fragments/files/status.json?target_type=${targetName}&target_id=${this.target.id}`
@ -127,7 +126,6 @@ export default class RagUploader extends Component {
<template> <template>
<div class="rag-uploader"> <div class="rag-uploader">
<h3>{{i18n "discourse_ai.rag.uploads.title"}}</h3>
{{#if @allowImages}} {{#if @allowImages}}
<p>{{i18n "discourse_ai.rag.uploads.description_with_images"}}</p> <p>{{i18n "discourse_ai.rag.uploads.description_with_images"}}</p>
{{else}} {{else}}

View File

@ -42,17 +42,8 @@
.ai-persona-editor { .ai-persona-editor {
padding-left: 0.5em; padding-left: 0.5em;
.fk-d-tooltip__icon {
padding-left: 0.25em;
color: var(--primary-medium);
}
label {
display: block;
}
&__tool-options { &__tool-options {
padding: 5px 10px 5px; padding: 1em;
border: 1px solid var(--primary-low-mid); border: 1px solid var(--primary-low-mid);
width: 480px; width: 480px;
} }
@ -61,27 +52,6 @@
margin-bottom: 10px; margin-bottom: 10px;
font-size: var(--font-down-1); font-size: var(--font-down-1);
} }
&__description {
width: 500px;
}
&__system_prompt {
width: 500px;
height: 400px;
}
&__tool-details,
&__vision_enabled,
&__allow_chat_direct_messages,
&__allow_chat_channel_mentions,
&__allow_topic_mentions,
&__allow_personal_messages,
&__force_default_llm,
&__priority {
display: flex;
align-items: center;
}
} }
.rag-options { .rag-options {

View File

@ -241,6 +241,7 @@ en:
custom: "Custom..." custom: "Custom..."
ai_persona: ai_persona:
ai_tools: "Tools"
tool_strategies: tool_strategies:
all: "Apply to all replies" all: "Apply to all replies"
replies: replies:
@ -306,7 +307,12 @@ en:
rag_conversation_chunks_help: "The number of chunks to use for the RAG model searches. Increase to increase the amount of context the AI can use." rag_conversation_chunks_help: "The number of chunks to use for the RAG model searches. Increase to increase the amount of context the AI can use."
persona_description: "Personas are a powerful feature that allows you to customize the behavior of the AI engine in your Discourse forum. They act as a 'system message' that guides the AI's responses and interactions, helping to create a more personalized and engaging user experience." persona_description: "Personas are a powerful feature that allows you to customize the behavior of the AI engine in your Discourse forum. They act as a 'system message' that guides the AI's responses and interactions, helping to create a more personalized and engaging user experience."
ai_bot:
title: "AI bot options"
save_first: "AI bot options will become available once you save the persona."
rag: rag:
title: "RAG"
options: options:
rag_chunk_tokens: "Upload chunk tokens" rag_chunk_tokens: "Upload chunk tokens"
rag_chunk_tokens_help: "The number of tokens to use for each chunk in the RAG model. Increase to increase the amount of context the AI can use. (changing will re-index all uploads)" rag_chunk_tokens_help: "The number of tokens to use for each chunk in the RAG model. Increase to increase the amount of context the AI can use. (changing will re-index all uploads)"

View File

@ -3,6 +3,7 @@
RSpec.describe "Admin AI persona configuration", type: :system, js: true do RSpec.describe "Admin AI persona configuration", type: :system, js: true do
fab!(:admin) fab!(:admin)
let(:page_header) { PageObjects::Components::DPageHeader.new } let(:page_header) { PageObjects::Components::DPageHeader.new }
let(:form) { PageObjects::Components::FormKit.new("form") }
before do before do
SiteSetting.ai_bot_enabled = true SiteSetting.ai_bot_enabled = true
@ -19,26 +20,23 @@ RSpec.describe "Admin AI persona configuration", type: :system, js: true do
expect(page_header).to be_hidden expect(page_header).to be_hidden
find(".ai-persona-editor__name").set("Test Persona") form.field("name").fill_in("Test Persona")
find(".ai-persona-editor__description").fill_in(with: "I am a test persona") form.field("description").fill_in("I am a test persona")
find(".ai-persona-editor__system_prompt").fill_in(with: "You are a helpful bot") form.field("system_prompt").fill_in("You are a helpful bot")
tool_selector = PageObjects::Components::SelectKit.new(".ai-persona-editor__tools") tool_selector = PageObjects::Components::SelectKit.new("#control-tools .select-kit")
tool_selector.expand tool_selector.expand
tool_selector.select_row_by_value("Read") tool_selector.select_row_by_value("Read")
tool_selector.collapse tool_selector.collapse
tool_selector = PageObjects::Components::SelectKit.new(".ai-persona-editor__forced_tools") tool_selector = PageObjects::Components::SelectKit.new("#control-forcedTools .select-kit")
tool_selector.expand tool_selector.expand
tool_selector.select_row_by_value("Read") tool_selector.select_row_by_value("Read")
tool_selector.collapse tool_selector.collapse
strategy_selector = form.field("forced_tool_count").select(1)
PageObjects::Components::SelectKit.new(".ai-persona-editor__forced_tool_strategy")
strategy_selector.expand
strategy_selector.select_row_by_value(1)
find(".ai-persona-editor__save").click() form.submit
expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai-personas/new") expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai-personas/new")
@ -55,7 +53,7 @@ RSpec.describe "Admin AI persona configuration", type: :system, js: true do
it "will not allow deletion or editing of system personas" do it "will not allow deletion or editing of system personas" do
visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}/edit" visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}/edit"
expect(page).not_to have_selector(".ai-persona-editor__delete") expect(page).not_to have_selector(".ai-persona-editor__delete")
expect(find(".ai-persona-editor__system_prompt")).to be_disabled expect(form.field("system_prompt")).to be_disabled
end end
it "will enable persona right away when you click on enable but does not save side effects" do it "will enable persona right away when you click on enable but does not save side effects" do
@ -63,8 +61,8 @@ RSpec.describe "Admin AI persona configuration", type: :system, js: true do
visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/edit" visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/edit"
find(".ai-persona-editor__name").set("Test Persona 1") form.field("name").fill_in("Test Persona 1")
PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle form.field("enabled").toggle
try_until_success { expect(persona.reload.enabled).to eq(true) } try_until_success { expect(persona.reload.enabled).to eq(true) }

View File

@ -71,7 +71,7 @@ describe "AI Tool Management", type: :system do
visit "/admin/plugins/discourse-ai/ai-personas/new" visit "/admin/plugins/discourse-ai/ai-personas/new"
tool_id = AiTool.order("id desc").limit(1).pluck(:id).first tool_id = AiTool.order("id desc").limit(1).pluck(:id).first
tool_selector = PageObjects::Components::SelectKit.new(".ai-persona-editor__tools") tool_selector = PageObjects::Components::SelectKit.new("#control-tools .select-kit")
tool_selector.expand tool_selector.expand
tool_selector.select_row_by_value("custom-#{tool_id}") tool_selector.select_row_by_value("custom-#{tool_id}")

View File

@ -2,33 +2,31 @@ import { module, test } from "qunit";
import AiPersona from "discourse/plugins/discourse-ai/discourse/admin/models/ai-persona"; import AiPersona from "discourse/plugins/discourse-ai/discourse/admin/models/ai-persona";
module("Discourse AI | Unit | Model | ai-persona", function () { module("Discourse AI | Unit | Model | ai-persona", function () {
test("init properties", function (assert) { test("toPOJO", function (assert) {
const properties = { const properties = {
tools: [ tools: [
["ToolName", { option1: "value1", option2: "value2" }], ["ToolName", { option1: "value1", option2: "value2" }, false],
"ToolName2", "ToolName2",
"ToolName3", "ToolName3",
], ],
}; };
const aiPersona = AiPersona.create(properties); const aiPersonaPOJO = AiPersona.create(properties).toPOJO();
assert.deepEqual(aiPersona.tools, ["ToolName", "ToolName2", "ToolName3"]); assert.deepEqual(aiPersonaPOJO.tools, [
assert.equal( "ToolName",
aiPersona.getToolOption("ToolName", "option1").value, "ToolName2",
"value1" "ToolName3",
); ]);
assert.equal( assert.equal(aiPersonaPOJO.toolOptions["ToolName"].option1, "value1");
aiPersona.getToolOption("ToolName", "option2").value, assert.equal(aiPersonaPOJO.toolOptions["ToolName"].option2, "value2");
"value2"
);
}); });
test("update properties", function (assert) { test("fromPOJO", function (assert) {
const properties = { const properties = {
id: 1, id: 1,
name: "Test", name: "Test",
tools: ["ToolName"], tools: [["ToolName", { option1: "value1" }, false]],
allowed_group_ids: [12], allowed_group_ids: [12],
system: false, system: false,
enabled: true, enabled: true,
@ -58,80 +56,19 @@ module("Discourse AI | Unit | Model | ai-persona", function () {
allow_chat_channel_mentions: true, allow_chat_channel_mentions: true,
allow_chat_direct_messages: true, allow_chat_direct_messages: true,
}; };
const updatedValue = "updated";
const aiPersona = AiPersona.create({ ...properties }); const aiPersona = AiPersona.create({ ...properties });
aiPersona.getToolOption("ToolName", "option1").value = "value1"; const personaPOJO = aiPersona.toPOJO();
const updatedProperties = aiPersona.updateProperties(); personaPOJO.toolOptions["ToolName"].option1 = updatedValue;
personaPOJO.forcedTools = "ToolName";
// perform remapping for save const updatedPersona = aiPersona.fromPOJO(personaPOJO);
properties.tools = [["ToolName", { option1: "value1" }, false]];
assert.deepEqual(updatedProperties, properties); assert.deepEqual(updatedPersona.tools, [
}); ["ToolName", { option1: updatedValue }, true],
]);
test("create properties", function (assert) {
const properties = {
id: 1,
name: "Test",
tools: ["ToolName"],
allowed_group_ids: [12],
system: false,
enabled: true,
system_prompt: "System Prompt",
priority: false,
description: "Description",
top_p: 0.8,
temperature: 0.7,
user: null,
user_id: null,
default_llm_id: 1,
max_context_posts: 5,
vision_enabled: true,
vision_max_pixels: 100,
rag_uploads: [],
rag_chunk_tokens: 374,
rag_chunk_overlap_tokens: 10,
rag_conversation_chunks: 10,
question_consolidator_llm_id: 2,
allow_chat: false,
tool_details: true,
forced_tool_count: -1,
allow_personal_messages: true,
allow_topic_mentions: true,
allow_chat_channel_mentions: true,
allow_chat_direct_messages: true,
force_default_llm: false,
rag_llm_model_id: 1,
};
const aiPersona = AiPersona.create({ ...properties });
aiPersona.getToolOption("ToolName", "option1").value = "value1";
const createdProperties = aiPersona.createProperties();
properties.tools = [["ToolName", { option1: "value1" }, false]];
assert.deepEqual(createdProperties, properties);
});
test("working copy", function (assert) {
const aiPersona = AiPersona.create({
name: "Test",
tools: ["ToolName"],
});
aiPersona.getToolOption("ToolName", "option1").value = "value1";
const workingCopy = aiPersona.workingCopy();
assert.equal(workingCopy.name, "Test");
assert.equal(
workingCopy.getToolOption("ToolName", "option1").value,
"value1"
);
assert.deepEqual(workingCopy.tools, ["ToolName"]);
}); });
}); });