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"; + + 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; + } + + +} 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"]); + }); +});