diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb
index f2e5b161..83708e7b 100644
--- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb
+++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb
@@ -14,7 +14,7 @@ module DiscourseAi
end
commands =
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
- { id: command.to_s.split("::").last, name: command.name.humanize.titleize }
+ AiCommandSerializer.new(command, root: false)
end
render json: { ai_personas: ai_personas, meta: { commands: commands } }
end
@@ -55,16 +55,36 @@ module DiscourseAi
end
def ai_persona_params
- params.require(:ai_persona).permit(
- :name,
- :description,
- :enabled,
- :system_prompt,
- :enabled,
- :priority,
- allowed_group_ids: [],
- commands: [],
- )
+ permitted =
+ params.require(:ai_persona).permit(
+ :name,
+ :description,
+ :enabled,
+ :system_prompt,
+ :priority,
+ allowed_group_ids: [],
+ )
+
+ if commands = params.dig(:ai_persona, :commands)
+ permitted[:commands] = permit_commands(commands)
+ end
+
+ permitted
+ end
+
+ def permit_commands(commands)
+ return [] if !commands.is_a?(Array)
+
+ commands.filter_map do |command, options|
+ break nil if !command.is_a?(String)
+ options&.permit! if options && options.is_a?(ActionController::Parameters)
+
+ if options
+ [command, options]
+ else
+ command
+ end
+ end
end
end
end
diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb
index 679e9599..206a046a 100644
--- a/app/models/ai_persona.rb
+++ b/app/models/ai_persona.rb
@@ -87,10 +87,23 @@ class AiPersona < ActiveRecord::Base
name = self.name
description = self.description
ai_persona_id = self.id
+
+ options = {}
+
commands =
- self.commands.filter_map do |inner_name|
+ self.commands.filter_map do |element|
+ inner_name = element
+ current_options = nil
+
+ if element.is_a?(Array)
+ inner_name = element[0]
+ current_options = element[1]
+ end
+
begin
- ("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
+ klass = ("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
+ options[klass] = current_options if current_options
+ klass
rescue StandardError
nil
end
@@ -134,6 +147,10 @@ class AiPersona < ActiveRecord::Base
commands
end
+ define_method :options do
+ options
+ end
+
define_method :system_prompt do
@ai_persona&.system_prompt || "You are a helpful bot."
end
diff --git a/app/serializers/ai_command_serializer.rb b/app/serializers/ai_command_serializer.rb
new file mode 100644
index 00000000..2333655f
--- /dev/null
+++ b/app/serializers/ai_command_serializer.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class AiCommandSerializer < ApplicationSerializer
+ attributes :options, :id, :name, :help
+
+ def include_options?
+ object.options.present?
+ end
+
+ def id
+ object.to_s.split("::").last
+ end
+
+ def name
+ object.name.humanize.titleize
+ end
+
+ def help
+ object.help
+ end
+
+ def options
+ options = {}
+ object.options.each do |option|
+ options[option.name] = {
+ name: option.localized_name,
+ description: option.localized_description,
+ type: option.type,
+ }
+ end
+ options
+ end
+end
diff --git a/assets/javascripts/discourse/admin/models/ai-persona.js b/assets/javascripts/discourse/admin/models/ai-persona.js
index 08fc8d28..d9152cd9 100644
--- a/assets/javascripts/discourse/admin/models/ai-persona.js
+++ b/assets/javascripts/discourse/admin/models/ai-persona.js
@@ -1,3 +1,4 @@
+import { tracked } from "@glimmer/tracking";
import RestModel from "discourse/models/rest";
const ATTRIBUTES = [
@@ -11,15 +12,81 @@ const ATTRIBUTES = [
"priority",
];
+class CommandOption {
+ @tracked value = null;
+}
+
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 commands as an Array.
+ // [[CommandName, {option1: value, option2: value}], CommandName2, CommandName3]
+ // So we rework this into a "commands" property and nested commandOptions
+ init(properties) {
+ if (properties.commands) {
+ properties.commands = properties.commands.map((command) => {
+ if (typeof command === "string") {
+ return command;
+ } else {
+ let [commandId, options] = command;
+ for (let optionId in options) {
+ if (!options.hasOwnProperty(optionId)) {
+ continue;
+ }
+ this.getCommandOption(commandId, optionId).value =
+ options[optionId];
+ }
+ return commandId;
+ }
+ });
+ }
+ super.init(properties);
+ this.commands = properties.commands;
+ }
+
+ getCommandOption(commandId, optionId) {
+ this.commandOptions ||= {};
+ this.commandOptions[commandId] ||= {};
+ return (this.commandOptions[commandId][optionId] ||= new CommandOption());
+ }
+
+ populateCommandOptions(attrs) {
+ if (!attrs.commands) {
+ return;
+ }
+ let commandsWithOptions = [];
+ attrs.commands.forEach((commandId) => {
+ if (typeof commandId !== "string") {
+ commandId = commandId[0];
+ }
+ if (this.commandOptions && this.commandOptions[commandId]) {
+ let options = this.commandOptions[commandId];
+ let optionsWithValues = {};
+ for (let optionId in options) {
+ if (!options.hasOwnProperty(optionId)) {
+ continue;
+ }
+ let option = options[optionId];
+ optionsWithValues[optionId] = option.value;
+ }
+ commandsWithOptions.push([commandId, optionsWithValues]);
+ } else {
+ commandsWithOptions.push(commandId);
+ }
+ });
+ attrs.commands = commandsWithOptions;
+ }
+
updateProperties() {
let attrs = this.getProperties(ATTRIBUTES);
attrs.id = this.id;
+ this.populateCommandOptions(attrs);
return attrs;
}
createProperties() {
- return this.getProperties(ATTRIBUTES);
+ let attrs = this.getProperties(ATTRIBUTES);
+ this.populateCommandOptions(attrs);
+ return attrs;
}
workingCopy() {
diff --git a/assets/javascripts/discourse/components/ai-persona-command-option-editor.gjs b/assets/javascripts/discourse/components/ai-persona-command-option-editor.gjs
new file mode 100644
index 00000000..e1957458
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-persona-command-option-editor.gjs
@@ -0,0 +1,15 @@
+import { Input } from "@ember/component";
+
+
+
+
+
+
+
+
+ {{@option.description}}
+
+
+
diff --git a/assets/javascripts/discourse/components/ai-persona-command-options.gjs b/assets/javascripts/discourse/components/ai-persona-command-options.gjs
new file mode 100644
index 00000000..dd792ecf
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-persona-command-options.gjs
@@ -0,0 +1,81 @@
+import Component from "@glimmer/component";
+import I18n from "discourse-i18n";
+import AiPersonaCommandOptionEditor from "./ai-persona-command-option-editor";
+
+export default class AiPersonaCommandOptions extends Component {
+ get showCommandOptions() {
+ const allCommands = this.args.allCommands;
+ if (!allCommands) {
+ return false;
+ }
+
+ return this.commandNames.any(
+ (command) => allCommands.find((c) => c.id === command)?.options
+ );
+ }
+
+ get commandNames() {
+ if (!this.args.commands) {
+ return [];
+ }
+ return this.args.commands.map((command) => {
+ if (typeof command === "string") {
+ return command;
+ } else {
+ return command[0];
+ }
+ });
+ }
+
+ get commandOptions() {
+ if (!this.args.commands) {
+ return [];
+ }
+
+ const allCommands = this.args.allCommands;
+ if (!allCommands) {
+ return [];
+ }
+
+ const options = [];
+ this.commandNames.forEach((commandId) => {
+ const command = allCommands.find((c) => c.id === commandId);
+
+ const commandName = command?.name;
+ const commandOptions = command?.options;
+
+ if (commandOptions) {
+ const mappedOptions = Object.keys(commandOptions).map((key) => {
+ const value = this.args.persona.getCommandOption(commandId, key);
+ return Object.assign({}, commandOptions[key], { id: key, value });
+ });
+
+ options.push({ commandName, options: mappedOptions });
+ }
+ });
+
+ return options;
+ }
+
+
+ {{#if this.showCommandOptions}}
+
+
+
+ {{#each this.commandOptions as |commandOption|}}
+
+
+ {{commandOption.commandName}}
+
+
+ {{#each commandOption.options as |option|}}
+
+ {{/each}}
+
+
+ {{/each}}
+
+
+ {{/if}}
+
+}
diff --git a/assets/javascripts/discourse/components/ai-persona-editor.gjs b/assets/javascripts/discourse/components/ai-persona-editor.gjs
index 47651ed2..be922cdb 100644
--- a/assets/javascripts/discourse/components/ai-persona-editor.gjs
+++ b/assets/javascripts/discourse/components/ai-persona-editor.gjs
@@ -12,11 +12,11 @@ import Textarea from "discourse/components/d-textarea";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
-import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import GroupChooser from "select-kit/components/group-chooser";
import DTooltip from "float-kit/components/d-tooltip";
import AiCommandSelector from "./ai-command-selector";
+import AiPersonaCommandOptions from "./ai-persona-command-options";
export default class PersonaEditor extends Component {
@service router;
@@ -159,7 +159,7 @@ export default class PersonaEditor extends Component {
/>
@@ -188,6 +188,13 @@ export default class PersonaEditor extends Component {
@commands={{@personas.resultSetMeta.commands}}
/>
+ {{#unless this.editingModel.system}}
+
+ {{/unless}}
%{title}"
time: "Time in %{timezone} is %{time}"
diff --git a/db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb b/db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb
new file mode 100644
index 00000000..477817be
--- /dev/null
+++ b/db/migrate/20231202013850_convert_ai_personas_commands_to_json.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+class ConvertAiPersonasCommandsToJson < ActiveRecord::Migration[7.0]
+ def up
+ # this all may be a bit surprising, but interestingly this makes all our backend code
+ # cross compatible
+ # upgrading ["a", "b", "c"] to json simply works cause in both cases
+ # rails will cast to a string array and all code simply expectes a string array
+ #
+ # this change was made so we can also start storing parameters with the commands
+ execute <<~SQL
+ ALTER TABLE ai_personas
+ ALTER COLUMN commands DROP DEFAULT
+ SQL
+
+ execute <<~SQL
+ ALTER TABLE ai_personas
+ ALTER COLUMN commands
+ TYPE json USING array_to_json(commands)
+ SQL
+
+ execute <<~SQL
+ ALTER TABLE ai_personas
+ ALTER COLUMN commands
+ SET DEFAULT '[]'::json
+ SQL
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+end
diff --git a/lib/ai_bot/commands/command.rb b/lib/ai_bot/commands/command.rb
index eae13910..1fa98417 100644
--- a/lib/ai_bot/commands/command.rb
+++ b/lib/ai_bot/commands/command.rb
@@ -3,18 +3,6 @@
module DiscourseAi
module AiBot
module Commands
- class Parameter
- attr_reader :item_type, :name, :description, :type, :enum, :required
- def initialize(name:, description:, type:, enum: nil, required: false, item_type: nil)
- @name = name
- @description = description
- @type = type
- @enum = enum
- @required = required
- @item_type = item_type
- end
- end
-
class Command
CARET = ""
PROGRESS_CARET = ""
@@ -38,6 +26,18 @@ module DiscourseAi
def parameters
raise NotImplemented
end
+
+ def options
+ []
+ end
+
+ def help
+ I18n.t("discourse_ai.ai_bot.command_help.#{name}")
+ end
+
+ def option(name, type:)
+ Option.new(command: self, name: name, type: type)
+ end
end
attr_reader :bot_user, :bot
@@ -63,6 +63,22 @@ module DiscourseAi
@invoked = false
end
+ def persona_options
+ return @persona_options if @persona_options
+
+ @persona_options = HashWithIndifferentAccess.new
+
+ # during tests we may operate without a bot
+ return @persona_options if !self.bot
+
+ self.class.options.each do |option|
+ val = self.bot.persona.options.dig(self.class, option.name)
+ @persona_options[option.name] = val if val
+ end
+
+ @persona_options
+ end
+
def tokenizer
bot.tokenizer
end
diff --git a/lib/ai_bot/commands/option.rb b/lib/ai_bot/commands/option.rb
new file mode 100644
index 00000000..4705dcba
--- /dev/null
+++ b/lib/ai_bot/commands/option.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+module DiscourseAi
+ module AiBot
+ module Commands
+ class Option
+ attr_reader :command, :name, :type
+ def initialize(command:, name:, type:)
+ @command = command
+ @name = name.to_s
+ @type = type
+ end
+
+ def localized_name
+ I18n.t("discourse_ai.ai_bot.command_options.#{command.name}.#{name}.name")
+ end
+
+ def localized_description
+ I18n.t("discourse_ai.ai_bot.command_options.#{command.name}.#{name}.description")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ai_bot/commands/search_command.rb b/lib/ai_bot/commands/search_command.rb
index 528191a7..7da2012b 100644
--- a/lib/ai_bot/commands/search_command.rb
+++ b/lib/ai_bot/commands/search_command.rb
@@ -11,6 +11,10 @@ module DiscourseAi::AiBot::Commands
"Will search topics in the current discourse instance, when rendering always prefer to link to the topics you find"
end
+ def options
+ [option(:base_query, type: :string)]
+ end
+
def parameters
[
Parameter.new(
@@ -119,6 +123,10 @@ module DiscourseAi::AiBot::Commands
show_progress(I18n.t("discourse_ai.ai_bot.searching", query: search_string))
+ if persona_options[:base_query].present?
+ search_string = "#{search_string} #{persona_options[:base_query]}"
+ end
+
results =
Search.execute(
search_string.to_s + " status:public",
diff --git a/lib/ai_bot/personas/persona.rb b/lib/ai_bot/personas/persona.rb
index 199216f8..3caf2a24 100644
--- a/lib/ai_bot/personas/persona.rb
+++ b/lib/ai_bot/personas/persona.rb
@@ -20,6 +20,10 @@ module DiscourseAi
[]
end
+ def options
+ {}
+ end
+
def render_commands(render_function_instructions:)
return +"" if available_commands.empty?
diff --git a/spec/lib/modules/ai_bot/commands/search_command_spec.rb b/spec/lib/modules/ai_bot/commands/search_command_spec.rb
index f4e6dbc6..7026aa22 100644
--- a/spec/lib/modules/ai_bot/commands/search_command_spec.rb
+++ b/spec/lib/modules/ai_bot/commands/search_command_spec.rb
@@ -4,9 +4,10 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
before { SearchIndexer.enable }
after { SearchIndexer.disable }
+ let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
+ fab!(:admin)
fab!(:parent_category) { Fabricate(:category, name: "animals") }
fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") }
-
fab!(:tag_funny) { Fabricate(:tag, name: "funny") }
fab!(:tag_sad) { Fabricate(:tag, name: "sad") }
fab!(:tag_hidden) { Fabricate(:tag, name: "hidden") }
@@ -25,7 +26,42 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
before { SiteSetting.ai_bot_enabled = true }
+ it "can properly list options" do
+ options = described_class.options
+ expect(options.length).to eq(1)
+ expect(options.first.name.to_s).to eq("base_query")
+ expect(options.first.localized_name).not_to include("Translation missing:")
+ expect(options.first.localized_description).not_to include("Translation missing:")
+ end
+
describe "#process" do
+ it "can retreive options from persona correctly" do
+ persona =
+ Fabricate(
+ :ai_persona,
+ allowed_group_ids: [Group::AUTO_GROUPS[:admins]],
+ commands: [["SearchCommand", { "base_query" => "#funny" }]],
+ )
+ Group.refresh_automatic_groups!
+
+ bot = DiscourseAi::AiBot::Bot.as(bot_user, persona_id: persona.id, user: admin)
+ search_post = Fabricate(:post, topic: topic_with_tags)
+
+ bot_post = Fabricate(:post)
+
+ search = described_class.new(bot: bot, post: bot_post, args: nil)
+
+ results = search.process(order: "latest")
+ expect(results[:rows].length).to eq(1)
+
+ search_post.topic.tags = []
+ search_post.topic.save!
+
+ # no longer has the tag funny
+ results = search.process(order: "latest")
+ expect(results[:rows].length).to eq(0)
+ end
+
it "can handle no results" do
post1 = Fabricate(:post, topic: topic_with_tags)
search = described_class.new(bot: nil, post: post1, args: nil)
diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb
index d67b6836..31d21163 100644
--- a/spec/requests/admin/ai_personas_controller_spec.rb
+++ b/spec/requests/admin/ai_personas_controller_spec.rb
@@ -18,6 +18,41 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
)
end
+ it "returns commands options with each command" do
+ persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"])
+ persona2 =
+ Fabricate(
+ :ai_persona,
+ name: "search2",
+ commands: [["SearchCommand", { base_query: "test" }]],
+ )
+
+ get "/admin/plugins/discourse-ai/ai_personas.json"
+ expect(response).to be_successful
+
+ serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id }
+ serializer_persona2 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona2.id }
+
+ commands = response.parsed_body["meta"]["commands"]
+ search_command = commands.find { |c| c["id"] == "SearchCommand" }
+
+ expect(search_command["help"]).to eq(I18n.t("discourse_ai.ai_bot.command_help.search"))
+
+ expect(search_command["options"]).to eq(
+ {
+ "base_query" => {
+ "type" => "string",
+ "name" => I18n.t("discourse_ai.ai_bot.command_options.search.base_query.name"),
+ "description" =>
+ I18n.t("discourse_ai.ai_bot.command_options.search.base_query.description"),
+ },
+ },
+ )
+
+ expect(serializer_persona1["commands"]).to eq(["SearchCommand"])
+ expect(serializer_persona2["commands"]).to eq([["SearchCommand", { "base_query" => "test" }]])
+ end
+
it "returns localized persona names and descriptions" do
SiteSetting.default_locale = "fr"
@@ -55,16 +90,20 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
name: "superbot",
description: "Assists with tasks",
system_prompt: "you are a helpful bot",
+ commands: [["search", { "base_query" => "test" }]],
}
end
it "creates a new AiPersona" do
expect {
post "/admin/plugins/discourse-ai/ai_personas.json",
- params: {
- ai_persona: valid_attributes,
+ params: { ai_persona: valid_attributes }.to_json,
+ headers: {
+ "CONTENT_TYPE" => "application/json",
}
expect(response).to be_successful
+ persona = AiPersona.find(response.parsed_body["ai_persona"]["id"])
+ expect(persona.commands).to eq([["search", { "base_query" => "test" }]])
}.to change(AiPersona, :count).by(1)
end
end
diff --git a/test/javascripts/unit/models/ai-persona-test.js b/test/javascripts/unit/models/ai-persona-test.js
new file mode 100644
index 00000000..0df91379
--- /dev/null
+++ b/test/javascripts/unit/models/ai-persona-test.js
@@ -0,0 +1,96 @@
+import { module, test } from "qunit";
+import AiPersona from "discourse/plugins/discourse-ai/discourse/admin/models/ai-persona";
+
+module("Discourse AI | Unit | Model | ai-persona", function () {
+ test("init properties", function (assert) {
+ const properties = {
+ commands: [
+ ["CommandName", { option1: "value1", option2: "value2" }],
+ "CommandName2",
+ "CommandName3",
+ ],
+ };
+
+ const aiPersona = AiPersona.create(properties);
+
+ assert.deepEqual(aiPersona.commands, [
+ "CommandName",
+ "CommandName2",
+ "CommandName3",
+ ]);
+ assert.equal(
+ aiPersona.getCommandOption("CommandName", "option1").value,
+ "value1"
+ );
+ assert.equal(
+ aiPersona.getCommandOption("CommandName", "option2").value,
+ "value2"
+ );
+ });
+
+ test("update properties", function (assert) {
+ const properties = {
+ id: 1,
+ name: "Test",
+ commands: ["CommandName"],
+ allowed_group_ids: [12],
+ system: false,
+ enabled: true,
+ system_prompt: "System Prompt",
+ priority: false,
+ description: "Description",
+ };
+
+ const aiPersona = AiPersona.create({ ...properties });
+
+ aiPersona.getCommandOption("CommandName", "option1").value = "value1";
+
+ const updatedProperties = aiPersona.updateProperties();
+
+ // perform remapping for save
+ properties.commands = [["CommandName", { option1: "value1" }]];
+
+ assert.deepEqual(updatedProperties, properties);
+ });
+
+ test("create properties", function (assert) {
+ const properties = {
+ name: "Test",
+ commands: ["CommandName"],
+ allowed_group_ids: [12],
+ system: false,
+ enabled: true,
+ system_prompt: "System Prompt",
+ priority: false,
+ description: "Description",
+ };
+
+ const aiPersona = AiPersona.create({ ...properties });
+
+ aiPersona.getCommandOption("CommandName", "option1").value = "value1";
+
+ const createdProperties = aiPersona.createProperties();
+
+ properties.commands = [["CommandName", { option1: "value1" }]];
+
+ assert.deepEqual(createdProperties, properties);
+ });
+
+ test("working copy", function (assert) {
+ const aiPersona = AiPersona.create({
+ name: "Test",
+ commands: ["CommandName"],
+ });
+
+ aiPersona.getCommandOption("CommandName", "option1").value = "value1";
+
+ const workingCopy = aiPersona.workingCopy();
+
+ assert.equal(workingCopy.name, "Test");
+ assert.equal(
+ workingCopy.getCommandOption("CommandName", "option1").value,
+ "value1"
+ );
+ assert.deepEqual(workingCopy.commands, ["CommandName"]);
+ });
+});