diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index 1a994aa2..0c5b75b1 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -3,7 +3,7 @@ module DiscourseAi module Admin class AiPersonasController < ::Admin::AdminController - before_action :find_ai_persona, only: %i[show update destroy] + before_action :find_ai_persona, only: %i[show update destroy create_user] def index ai_personas = @@ -16,7 +16,11 @@ module DiscourseAi DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool| AiToolSerializer.new(tool, root: false) end - render json: { ai_personas: ai_personas, meta: { commands: tools } } + llms = + DiscourseAi::Configuration::LlmEnumerator.values.map do |hash| + { id: hash[:value], name: hash[:name] } + end + render json: { ai_personas: ai_personas, meta: { commands: tools, llms: llms } } end def show @@ -32,6 +36,11 @@ module DiscourseAi end end + def create_user + user = @ai_persona.create_user! + render json: BasicUserSerializer.new(user, root: "user") + end + def update if @ai_persona.update(ai_persona_params) render json: @ai_persona @@ -64,6 +73,10 @@ module DiscourseAi :priority, :top_p, :temperature, + :default_llm, + :user_id, + :mentionable, + :max_context_posts, allowed_group_ids: [], ) diff --git a/app/jobs/regular/create_ai_reply.rb b/app/jobs/regular/create_ai_reply.rb index c634e380..16f24b2e 100644 --- a/app/jobs/regular/create_ai_reply.rb +++ b/app/jobs/regular/create_ai_reply.rb @@ -7,22 +7,11 @@ module ::Jobs def execute(args) return unless bot_user = User.find_by(id: args[:bot_user_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id]) + persona_id = args[:persona_id] begin - persona = nil - if persona_id = post.topic.custom_fields["ai_persona_id"] - persona = - DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) - raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? - end - - if !persona && persona_name = post.topic.custom_fields["ai_persona"] - persona = - DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name) - raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? - end - - persona ||= DiscourseAi::AiBot::Personas::General + persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id) + raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil? bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb index 54e54020..587d2872 100644 --- a/app/models/ai_persona.rb +++ b/app/models/ai_persona.rb @@ -8,6 +8,10 @@ class AiPersona < ActiveRecord::Base validates :description, presence: true, length: { maximum: 2000 } validates :system_prompt, presence: true, length: { maximum: 10_000_000 } validate :system_persona_unchangeable, on: :update, if: :system + validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true + + belongs_to :created_by, class_name: "User" + belongs_to :user before_destroy :ensure_not_system @@ -56,6 +60,23 @@ class AiPersona < ActiveRecord::Base .map(&:class_instance) end + def self.mentionables + persona_cache[:mentionable_usernames] ||= AiPersona + .where(mentionable: true) + .where(enabled: true) + .joins(:user) + .pluck("ai_personas.id, users.id, users.username_lower, allowed_group_ids, default_llm") + .map do |id, user_id, username, allowed_group_ids, default_llm| + { + id: id, + user_id: user_id, + username: username, + allowed_group_ids: allowed_group_ids, + default_llm: default_llm, + } + end + end + after_commit :bump_cache def bump_cache @@ -66,6 +87,10 @@ class AiPersona < ActiveRecord::Base allowed_group_ids = self.allowed_group_ids id = self.id system = self.system + user_id = self.user_id + mentionable = self.mentionable + default_llm = self.default_llm + max_context_posts = self.max_context_posts persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id] if persona_class @@ -81,6 +106,22 @@ class AiPersona < ActiveRecord::Base system end + persona_class.define_singleton_method :user_id do + user_id + end + + persona_class.define_singleton_method :mentionable do + mentionable + end + + persona_class.define_singleton_method :default_llm do + default_llm + end + + persona_class.define_singleton_method :max_context_posts do + max_context_posts + end + return persona_class end @@ -124,6 +165,10 @@ class AiPersona < ActiveRecord::Base name end + define_singleton_method :user_id do + user_id + end + define_singleton_method :description do description end @@ -136,6 +181,22 @@ class AiPersona < ActiveRecord::Base allowed_group_ids end + define_singleton_method :user_id do + user_id + end + + define_singleton_method :mentionable do + mentionable + end + + define_singleton_method :default_llm do + default_llm + end + + define_singleton_method :max_context_posts do + max_context_posts + end + define_singleton_method :to_s do "#" end @@ -171,6 +232,45 @@ class AiPersona < ActiveRecord::Base end end + FIRST_PERSONA_USER_ID = -1200 + + def create_user! + raise "User already exists" if user_id && User.exists?(user_id) + + # find the first id smaller than FIRST_USER_ID that is not taken + id = nil + + id = DB.query_single(<<~SQL, FIRST_PERSONA_USER_ID, FIRST_PERSONA_USER_ID - 200).first + WITH seq AS ( + SELECT generate_series(?, ?, -1) AS id + ) + SELECT seq.id FROM seq + LEFT JOIN users ON users.id = seq.id + WHERE users.id IS NULL + ORDER BY seq.id DESC + SQL + + id = DB.query_single(<<~SQL).first if id.nil? + SELECT min(id) - 1 FROM users + SQL + + # note .invalid is a reserved TLD which will route nowhere + user = + User.new( + email: "#{SecureRandom.hex}@does-not-exist.invalid", + name: name.titleize, + username: UserNameSuggester.suggest(name + "_bot"), + active: true, + approved: true, + trust_level: TrustLevel[4], + id: id, + ) + user.save!(validate: false) + + update!(user_id: user.id) + user + end + private def system_persona_unchangeable @@ -192,20 +292,26 @@ end # # Table name: ai_personas # -# id :bigint not null, primary key -# name :string(100) not null -# description :string(2000) not null -# commands :json not null -# system_prompt :string(10000000) not null -# allowed_group_ids :integer default([]), not null, is an Array -# created_by_id :integer -# enabled :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# system :boolean default(FALSE), not null -# priority :boolean default(FALSE), not null -# temperature :float -# top_p :float +# id :bigint not null, primary key +# name :string(100) not null +# description :string(2000) not null +# commands :json not null +# system_prompt :string(10000000) not null +# allowed_group_ids :integer default([]), not null, is an Array +# created_by_id :integer +# enabled :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# system :boolean default(FALSE), not null +# priority :boolean default(FALSE), not null +# temperature :float +# top_p :float +# user_id :integer +# mentionable :boolean default(FALSE), not null +# default_llm :text +# max_context_posts :integer +# max_post_context_tokens :integer +# max_context_tokens :integer # # Indexes # diff --git a/app/serializers/localized_ai_persona_serializer.rb b/app/serializers/localized_ai_persona_serializer.rb index fca5f6d2..cebaf694 100644 --- a/app/serializers/localized_ai_persona_serializer.rb +++ b/app/serializers/localized_ai_persona_serializer.rb @@ -13,7 +13,13 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer :system_prompt, :allowed_group_ids, :temperature, - :top_p + :top_p, + :mentionable, + :default_llm, + :user_id, + :max_context_posts + + has_one :user, serializer: BasicUserSerializer, embed: :object def name object.class_instance.name diff --git a/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js index ed0b33b5..aff18154 100644 --- a/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js +++ b/assets/javascripts/discourse/admin-discourse-ai-personas-route-map.js @@ -5,7 +5,7 @@ export default { map() { this.route("discourse-ai", function () { - this.route("ai-personas", { path: "ai_personas" }, function () { + this.route("ai-personas", function () { this.route("new"); this.route("show", { path: "/:id" }); }); diff --git a/assets/javascripts/discourse/admin/adapters/ai-persona.js b/assets/javascripts/discourse/admin/adapters/ai-persona.js index 309eb273..6a837690 100644 --- a/assets/javascripts/discourse/admin/adapters/ai-persona.js +++ b/assets/javascripts/discourse/admin/adapters/ai-persona.js @@ -7,8 +7,12 @@ export default class Adapter extends RestAdapter { return "/admin/plugins/discourse-ai/"; } - pathFor() { - return super.pathFor(...arguments) + ".json"; + pathFor(store, type, findArgs) { + // removes underscores which are implemented in base + let path = + this.basePath(store, type, findArgs) + + store.pluralize(this.apiNameFor(type)); + return this.appendQueryParams(path, findArgs); } apiNameFor() { diff --git a/assets/javascripts/discourse/admin/models/ai-persona.js b/assets/javascripts/discourse/admin/models/ai-persona.js index 8d7c4171..80cca511 100644 --- a/assets/javascripts/discourse/admin/models/ai-persona.js +++ b/assets/javascripts/discourse/admin/models/ai-persona.js @@ -1,4 +1,5 @@ import { tracked } from "@glimmer/tracking"; +import { ajax } from "discourse/lib/ajax"; import RestModel from "discourse/models/rest"; const ATTRIBUTES = [ @@ -12,6 +13,11 @@ const ATTRIBUTES = [ "priority", "top_p", "temperature", + "user_id", + "mentionable", + "default_llm", + "user", + "max_context_posts", ]; class CommandOption { @@ -45,6 +51,18 @@ export default class AiPersona extends RestModel { this.commands = properties.commands; } + async createUser() { + const result = await ajax( + `/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`, + { + type: "POST", + } + ); + this.user = result.user; + this.user_id = this.user.id; + return this.user; + } + getCommandOption(commandId, optionId) { this.commandOptions ||= {}; this.commandOptions[commandId] ||= {}; diff --git a/assets/javascripts/discourse/components/ai-llm-selector.js b/assets/javascripts/discourse/components/ai-llm-selector.js new file mode 100644 index 00000000..be372885 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-llm-selector.js @@ -0,0 +1,22 @@ +import { computed, observer } from "@ember/object"; +import I18n from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; + +export default ComboBox.extend({ + _modelDisabledChanged: observer("attrs.disabled", function () { + this.selectKit.options.set("disabled", this.get("attrs.disabled.value")); + }), + + content: computed(function () { + return [ + { + id: "blank", + name: I18n.t("discourse_ai.ai_persona.no_llm_selected"), + }, + ].concat(this.llms); + }), + + selectKitOptions: { + filterable: true, + }, +}); diff --git a/assets/javascripts/discourse/components/ai-persona-editor.gjs b/assets/javascripts/discourse/components/ai-persona-editor.gjs index b172d0de..ddb0e32e 100644 --- a/assets/javascripts/discourse/components/ai-persona-editor.gjs +++ b/assets/javascripts/discourse/components/ai-persona-editor.gjs @@ -5,17 +5,21 @@ 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 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 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"; export default class PersonaEditor extends Component { @@ -81,6 +85,22 @@ export default class PersonaEditor extends Component { return this.editingModel?.top_p || !this.editingModel?.system; } + get adminUser() { + return AdminUser.create(this.editingModel?.user); + } + + 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 delete() { return this.dialog.confirm({ @@ -103,26 +123,42 @@ export default class PersonaEditor extends Component { @action async toggleEnabled() { - this.args.model.set("enabled", !this.args.model.enabled); - this.editingModel.set("enabled", this.args.model.enabled); - if (!this.args.model.isNew) { - try { - await this.args.model.update({ enabled: this.args.model.enabled }); - } catch (e) { - popupAjaxError(e); - } - } + await this.toggleField("enabled"); } @action async togglePriority() { - this.args.model.set("priority", !this.args.model.priority); - this.editingModel.set("priority", this.args.model.priority); + await this.toggleField("priority", true); + } + + @action + async toggleMentionable() { + await this.toggleField("mentionable"); + } + + @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); + } + } + + 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 { - await this.args.model.update({ priority: this.args.model.priority }); + const args = {}; + args[field] = this.args.model[field]; - this.#sortPersonas(); + await this.args.model.update(args); + if (sortPersonas) { + this.#sortPersonas(); + } } catch (e) { popupAjaxError(e); } @@ -170,6 +206,20 @@ export default class PersonaEditor extends Component { @content={{I18n.t "discourse_ai.ai_persona.priority_help"}} /> + {{#if this.editingModel.user}} +
+ + +
+ {{/if}}
+
+ + + +
+ {{#unless @model.isNew}} +
+ + {{#if this.editingModel.user}} + + {{Avatar this.editingModel.user.avatar_template "small"}} + + + {{this.editingModel.user.username}} + + {{else}} + + {{I18n.t "discourse_ai.ai_persona.create_user"}} + + + {{/if}} +
+ {{/unless}}
+
+ + + +
{{#if this.showTemperature}}
diff --git a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss index 80f54644..96df9d12 100644 --- a/assets/stylesheets/modules/ai-bot/common/ai-persona.scss +++ b/assets/stylesheets/modules/ai-bot/common/ai-persona.scss @@ -31,6 +31,10 @@ } .ai-persona-editor { + .fk-d-tooltip__icon { + padding-left: 0.25em; + color: var(--primary-medium); + } label { display: block; } @@ -53,10 +57,9 @@ &__priority { display: flex; align-items: center; - - .fk-d-tooltip__icon { - padding-left: 0.25em; - color: var(--primary-medium); - } + } + &__mentionable { + display: flex; + align-items: center; } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 4d3e9a61..a2f1eb9d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -108,6 +108,16 @@ en: ai_persona: name: Name description: Description + no_llm_selected: "No language model selected" + max_context_posts: "Max Context Posts" + max_context_posts_help: "The maximum number of posts to use as context for the AI when responding to a user. (empty for default)" + mentionable: Mentionable + mentionable_help: If enabled, users in allowed groups can mention this user in posts and messages, the AI will respond as this persona. + user: User + create_user: Create User + create_user_help: You can optionally attach a user to this persona. If you do, the AI will use this user to respond to requests. + default_llm: Default Language Model + default_llm_help: The default language model to use for this persona. Required if you wish to mention persona on public posts. system_prompt: System Prompt save: Save saved: AI Persona Saved diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index cd747829..79ab4877 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -182,6 +182,7 @@ en: name: "Base Search Query" description: "Base query to use when searching. Example: '#urgent' will prepend '#urgent' to the search query and only include topics with the urgent category or tag." command_summary: + random_picker: "Random Picker" categories: "List categories" search: "Search" tags: "List tags" @@ -195,6 +196,7 @@ en: search_settings: "Searching site settings" dall_e: "Generate image" command_help: + random_picker: "Pick a random number or a random element of a list" categories: "List all publicly visible categories on the forum" search: "Search all public topics on the forum" tags: "List all tags on the forum" @@ -208,6 +210,7 @@ en: search_settings: "Search site settings" dall_e: "Generate image using DALL-E 3" command_description: + random_picker: "Picking from %{options}, picked: %{result}" read: "Reading: %{title}" time: "Time in %{timezone} is %{time}" summarize: "Summarized %{title}" @@ -268,6 +271,6 @@ en: disable_embeddings: "You have to disable 'ai embeddings enabled' first." choose_model: "Set 'ai embeddings model' first." model_unreachable: "We failed to generate a test embedding with this model. Check your settings are correct." - hint: + hint: one: "Make sure the `%{settings}` setting was configured." other: "Make sure the settings of the provider you want were configured. Options are: %{settings}" diff --git a/config/routes.rb b/config/routes.rb index 66a29e02..54d6820f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,9 +27,13 @@ Discourse::Application.routes.draw do :constraints => StaffConstraint.new scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do - get "/", to: redirect("/admin/plugins/discourse-ai/ai_personas") + get "/", to: redirect("/admin/plugins/discourse-ai/ai-personas") + resources :ai_personas, only: %i[index create show update destroy], + path: "ai-personas", controller: "discourse_ai/admin/ai_personas" + + post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user" end end diff --git a/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb b/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb new file mode 100644 index 00000000..cf5e0315 --- /dev/null +++ b/db/migrate/20240209044519_add_user_id_mentionable_default_llm_to_ai_personas.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +# +class AddUserIdMentionableDefaultLlmToAiPersonas < ActiveRecord::Migration[7.0] + def change + change_table :ai_personas do |t| + t.integer :user_id, null: true + t.boolean :mentionable, default: false, null: false + t.text :default_llm, null: true, length: 250 + end + end +end diff --git a/db/migrate/20240213051213_add_limits_to_ai_persona.rb b/db/migrate/20240213051213_add_limits_to_ai_persona.rb new file mode 100644 index 00000000..fac906e4 --- /dev/null +++ b/db/migrate/20240213051213_add_limits_to_ai_persona.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLimitsToAiPersona < ActiveRecord::Migration[7.0] + def change + change_table :ai_personas do |t| + t.integer :max_context_posts, null: true + end + end +end diff --git a/lib/ai_bot/bot.rb b/lib/ai_bot/bot.rb index 655f1c8a..c7a38496 100644 --- a/lib/ai_bot/bot.rb +++ b/lib/ai_bot/bot.rb @@ -16,6 +16,7 @@ module DiscourseAi end attr_reader :bot_user + attr_accessor :persona def get_updated_title(conversation_context, post_user) system_insts = <<~TEXT.strip @@ -111,8 +112,6 @@ module DiscourseAi raw_context end - attr_reader :persona - private def invoke_tool(tool, llm, cancel, &update_blk) diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index f0a48fa0..da8e8183 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -23,6 +23,8 @@ module DiscourseAi [FAKE_ID, "fake_bot", "fake"], ] + BOT_USER_IDS = BOTS.map(&:first) + def self.map_bot_model_to_user_id(model_name) case model_name in "gpt-4-turbo" @@ -111,16 +113,7 @@ module DiscourseAi name || topic.custom_fields["ai_persona"] end - plugin.on(:post_created) do |post| - bot_ids = BOTS.map(&:first) - - # Don't schedule a reply for a bot reply. - if !bot_ids.include?(post.user_id) - bot_user = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user - bot = DiscourseAi::AiBot::Bot.as(bot_user) - DiscourseAi::AiBot::Playground.new(bot).update_playground_with(post) - end - end + plugin.on(:post_created) { |post| DiscourseAi::AiBot::Playground.schedule_reply(post) } if plugin.respond_to?(:register_editable_topic_custom_field) plugin.register_editable_topic_custom_field(:ai_persona_id) diff --git a/lib/ai_bot/personas/persona.rb b/lib/ai_bot/personas/persona.rb index e56d3362..4db8690b 100644 --- a/lib/ai_bot/personas/persona.rb +++ b/lib/ai_bot/personas/persona.rb @@ -61,6 +61,7 @@ module DiscourseAi Tools::SearchSettings, Tools::Summarize, Tools::SettingContext, + Tools::RandomPicker, ] tools << Tools::ListTags if SiteSetting.tagging_enabled diff --git a/lib/ai_bot/playground.rb b/lib/ai_bot/playground.rb index a4116439..65ea6788 100644 --- a/lib/ai_bot/playground.rb +++ b/lib/ai_bot/playground.rb @@ -3,26 +3,101 @@ module DiscourseAi module AiBot class Playground + attr_reader :bot + # An abstraction to manage the bot and topic interactions. # The bot will take care of completions while this class updates the topic title # and stream replies. REQUIRE_TITLE_UPDATE = "discourse-ai-title-update" + def self.schedule_reply(post) + bot_ids = DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS + + return if bot_ids.include?(post.user_id) + if AiPersona.mentionables.any? { |mentionable| mentionable[:user_id] == post.user_id } + return + end + + bot_user = nil + mentioned = nil + + if post.topic.private_message? + bot_user = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user + end + + if AiPersona.mentionables.length > 0 + mentions = post.mentions.map(&:downcase) + mentioned = + AiPersona.mentionables.find do |mentionable| + mentions.include?(mentionable[:username]) && + (post.user.group_ids & mentionable[:allowed_group_ids]).present? + end + + # PM always takes precedence + if mentioned && !bot_user + model_without_provider = mentioned[:default_llm].split(":").last + user_id = + DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(model_without_provider) + + if !user_id + Rails.logger.warn( + "Model #{mentioned[:default_llm]} not found for persona #{mentioned[:username]}", + ) + if Rails.env.development? || Rails.env.test? + raise "Model #{mentioned[:default_llm]} not found for persona #{mentioned[:username]}" + end + else + bot_user = User.find_by(id: user_id) + end + end + end + + if bot_user + persona_id = mentioned&.dig(:id) || post.topic.custom_fields["ai_persona_id"] + persona = nil + + if persona_id + persona = + DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id.to_i) + end + + if !persona && persona_name = post.topic.custom_fields["ai_persona"] + persona = + DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, name: persona_name) + end + + persona ||= DiscourseAi::AiBot::Personas::General + + bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new) + new(bot).update_playground_with(post) + end + end + def initialize(bot) @bot = bot end def update_playground_with(post) - if can_attach?(post) && bot.bot_user - schedule_playground_titling(post, bot.bot_user) - schedule_bot_reply(post, bot.bot_user) + if can_attach?(post) + schedule_playground_titling(post) + schedule_bot_reply(post) end end def conversation_context(post) # Pay attention to the `post_number <= ?` here. # We want to inject the last post as context because they are translated differently. + + # also setting default to 40, allowing huge contexts costs lots of tokens + max_posts = 40 + if bot.persona.class.respond_to?(:max_context_posts) + max_posts = bot.persona.class.max_context_posts || 40 + end + + post_types = [Post.types[:regular]] + post_types << Post.types[:whisper] if post.post_type == Post.types[:whisper] + context = post .topic @@ -31,8 +106,8 @@ module DiscourseAi .joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id") .where("post_number <= ?", post.post_number) .order("post_number desc") - .where("post_type = ?", Post.types[:regular]) - .limit(50) + .where("post_type in (?)", post_types) + .limit(max_posts) .pluck(:raw, :username, "post_custom_prompts.custom_prompt") result = [] @@ -96,6 +171,9 @@ module DiscourseAi reply = +"" start = Time.now + post_type = + post.post_type == Post.types[:whisper] ? Post.types[:whisper] : Post.types[:regular] + context = { site_url: Discourse.base_url, site_title: SiteSetting.title, @@ -106,19 +184,36 @@ module DiscourseAi user: post.user, } - reply_post = - PostCreator.create!( - bot.bot_user, - topic_id: post.topic_id, - raw: "", - skip_validations: true, - skip_jobs: true, - ) + reply_user = bot.bot_user + if bot.persona.class.respond_to?(:user_id) + reply_user = User.find_by(id: bot.persona.class.user_id) || reply_user + end - publish_update(reply_post, { raw: reply_post.cooked }) + stream_reply = post.topic.private_message? - redis_stream_key = "gpt_cancel:#{reply_post.id}" - Discourse.redis.setex(redis_stream_key, 60, 1) + # we need to ensure persona user is allowed to reply to the pm + if post.topic.private_message? + if !post.topic.topic_allowed_users.exists?(user_id: reply_user.id) + post.topic.topic_allowed_users.create!(user_id: reply_user.id) + end + end + + if stream_reply + reply_post = + PostCreator.create!( + reply_user, + topic_id: post.topic_id, + raw: "", + skip_validations: true, + skip_jobs: true, + post_type: post_type, + ) + + publish_update(reply_post, { raw: reply_post.cooked }) + + redis_stream_key = "gpt_cancel:#{reply_post.id}" + Discourse.redis.setex(redis_stream_key, 60, 1) + end new_custom_prompts = bot.reply(context) do |partial, cancel, placeholder| @@ -126,30 +221,47 @@ module DiscourseAi raw = reply.dup raw << "\n\n" << placeholder if placeholder.present? - if !Discourse.redis.get(redis_stream_key) + if stream_reply && !Discourse.redis.get(redis_stream_key) cancel&.call - reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) end - # Minor hack to skip the delay during tests. - if placeholder.blank? - next if (Time.now - start < 0.5) && !Rails.env.test? - start = Time.now + if stream_reply + # Minor hack to skip the delay during tests. + if placeholder.blank? + next if (Time.now - start < 0.5) && !Rails.env.test? + start = Time.now + end + + Discourse.redis.expire(redis_stream_key, 60) + + publish_update(reply_post, { raw: raw }) end - - Discourse.redis.expire(redis_stream_key, 60) - - publish_update(reply_post, { raw: raw }) end return if reply.blank? - # land the final message prior to saving so we don't clash - reply_post.cooked = PrettyText.cook(reply) - publish_final_update(reply_post) + if stream_reply + # land the final message prior to saving so we don't clash + reply_post.cooked = PrettyText.cook(reply) + publish_final_update(reply_post) - reply_post.revise(bot.bot_user, { raw: reply }, skip_validations: true, skip_revision: true) + reply_post.revise( + bot.bot_user, + { raw: reply }, + skip_validations: true, + skip_revision: true, + ) + else + reply_post = + PostCreator.create!( + reply_user, + topic_id: post.topic_id, + raw: reply, + skip_validations: true, + post_type: post_type, + ) + end # not need to add a custom prompt for a single reply if new_custom_prompts.length > 1 @@ -161,7 +273,7 @@ module DiscourseAi reply_post ensure - publish_final_update(reply_post) + publish_final_update(reply_post) if stream_reply end private @@ -179,33 +291,38 @@ module DiscourseAi end end - attr_reader :bot - def can_attach?(post) return false if bot.bot_user.nil? - return false if post.post_type != Post.types[:regular] - return false unless post.topic.private_message? + return false if post.topic.private_message? && post.post_type != Post.types[:regular] return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank? true end - def schedule_playground_titling(post, bot_user) - if post.post_number == 1 + def schedule_playground_titling(post) + if post.post_number == 1 && post.topic.private_message? post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true post.topic.save_custom_fields - end - ::Jobs.enqueue_in( - 5.minutes, - :update_ai_bot_pm_title, - post_id: post.id, - bot_user_id: bot_user.id, - ) + ::Jobs.enqueue_in( + 5.minutes, + :update_ai_bot_pm_title, + post_id: post.id, + bot_user_id: bot.bot_user.id, + ) + end end - def schedule_bot_reply(post, bot_user) - ::Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_user.id) + def schedule_bot_reply(post) + persona_id = + DiscourseAi::AiBot::Personas::Persona.system_personas[bot.persona.class] || + bot.persona.class.id + ::Jobs.enqueue( + :create_ai_reply, + post_id: post.id, + bot_user_id: bot.bot_user.id, + persona_id: persona_id, + ) end def context(topic) diff --git a/lib/ai_bot/site_settings_extension.rb b/lib/ai_bot/site_settings_extension.rb index 4e8d11a2..29a404e3 100644 --- a/lib/ai_bot/site_settings_extension.rb +++ b/lib/ai_bot/site_settings_extension.rb @@ -4,11 +4,11 @@ module DiscourseAi::AiBot::SiteSettingsExtension def self.enable_or_disable_ai_bots enabled_bots = SiteSetting.ai_bot_enabled_chat_bots_map enabled_bots = [] if !SiteSetting.ai_bot_enabled + DiscourseAi::AiBot::EntryPoint::BOTS.each do |id, bot_name, name| if id == DiscourseAi::AiBot::EntryPoint::FAKE_ID next if Rails.env.production? end - active = enabled_bots.include?(name) user = User.find_by(id: id) diff --git a/lib/ai_bot/tools/random_picker.rb b/lib/ai_bot/tools/random_picker.rb new file mode 100644 index 00000000..60000bb6 --- /dev/null +++ b/lib/ai_bot/tools/random_picker.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + module Tools + class RandomPicker < Tool + def self.signature + { + name: name, + description: + "Handles a variety of random decisions based on the format of each input element", + parameters: [ + { + name: "options", + description: + "An array where each element is either a range (e.g., '1-6') or a comma-separated list of options (e.g., 'sam,jane,joe')", + type: "array", + item_type: "string", + required: true, + }, + ], + } + end + + def self.name + "random_picker" + end + + def options + parameters[:options] + end + + def invoke(_bot_user, _llm) + result = nil + # can be a naive list of strings + if options.none? { |option| option.match?(/\A\d+-\d+\z/) || option.include?(",") } + result = options.sample + else + result = + options.map do |option| + case option + when /\A\d+-\d+\z/ # Range format, e.g., "1-6" + random_range(option) + when /,/ # Comma-separated values, e.g., "sam,jane,joe" + pick_list(option) + else + "Invalid format: #{option}" + end + end + end + + @last_result = result + { options: options, result: result } + end + + private + + def random_range(range_str) + low, high = range_str.split("-").map(&:to_i) + rand(low..high) + end + + def pick_list(list_str) + list_str.split(",").map(&:strip).sample + end + + def description_args + { options: options, result: @last_result } + end + end + end + end +end diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb index a7932fe9..5bae7500 100644 --- a/lib/completions/llm.rb +++ b/lib/completions/llm.rb @@ -21,37 +21,50 @@ module DiscourseAi def models_by_provider # ChatGPT models are listed under open_ai but they are actually available through OpenAI and Azure. # However, since they use the same URL/key settings, there's no reason to duplicate them. - { - aws_bedrock: %w[claude-instant-1 claude-2], - anthropic: %w[claude-instant-1 claude-2], - vllm: %w[ - mistralai/Mixtral-8x7B-Instruct-v0.1 - mistralai/Mistral-7B-Instruct-v0.2 - StableBeluga2 - Upstage-Llama-2-*-instruct-v2 - Llama2-*-chat-hf - Llama2-chat-hf - ], - hugging_face: %w[ - mistralai/Mixtral-8x7B-Instruct-v0.1 - mistralai/Mistral-7B-Instruct-v0.2 - StableBeluga2 - Upstage-Llama-2-*-instruct-v2 - Llama2-*-chat-hf - Llama2-chat-hf - ], - open_ai: %w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k gpt-4-turbo], - google: %w[gemini-pro], - }.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? } + @models_by_provider ||= + { + aws_bedrock: %w[claude-instant-1 claude-2], + anthropic: %w[claude-instant-1 claude-2], + vllm: %w[ + mistralai/Mixtral-8x7B-Instruct-v0.1 + mistralai/Mistral-7B-Instruct-v0.2 + StableBeluga2 + Upstage-Llama-2-*-instruct-v2 + Llama2-*-chat-hf + Llama2-chat-hf + ], + hugging_face: %w[ + mistralai/Mixtral-8x7B-Instruct-v0.1 + mistralai/Mistral-7B-Instruct-v0.2 + StableBeluga2 + Upstage-Llama-2-*-instruct-v2 + Llama2-*-chat-hf + Llama2-chat-hf + ], + open_ai: %w[gpt-3.5-turbo gpt-4 gpt-3.5-turbo-16k gpt-4-32k gpt-4-turbo], + google: %w[gemini-pro], + }.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? } end - def with_prepared_responses(responses) - @canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) + def valid_provider_models + return @valid_provider_models if defined?(@valid_provider_models) - yield(@canned_response) + valid_provider_models = [] + models_by_provider.each do |provider, models| + valid_provider_models.concat(models.map { |model| "#{provider}:#{model}" }) + end + @valid_provider_models = Set.new(valid_provider_models) + end + + def with_prepared_responses(responses, llm: nil) + @canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses) + @canned_llm = llm + + yield(@canned_response, llm) ensure # Don't leak prepared response if there's an exception. @canned_response = nil + @canned_llm = nil end def proxy(model_name) @@ -63,7 +76,12 @@ module DiscourseAi dialect_klass = DiscourseAi::Completions::Dialects::Dialect.dialect_for(model_name_without_prov) - return new(dialect_klass, @canned_response, model_name) if @canned_response + if @canned_response + if @canned_llm && @canned_llm != model_name + raise "Invalid call LLM call, expected #{@canned_llm} but got #{model_name}" + end + return new(dialect_klass, @canned_response, model_name) + end gateway = DiscourseAi::Completions::Endpoints::Base.endpoint_for( diff --git a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb index 8fe58306..ce78af12 100644 --- a/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb +++ b/spec/lib/modules/ai_bot/jobs/regular/create_ai_reply_spec.rb @@ -14,10 +14,12 @@ RSpec.describe Jobs::CreateAiReply do before { SiteSetting.min_personal_message_post_length = 5 } it "adds a reply from the bot" do + persona_id = AiPersona.find_by(name: "Forum Helper").id DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do subject.execute( post_id: topic.first_post.id, bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID, + persona_id: persona_id, ) end diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb index 63a3c16e..918a3973 100644 --- a/spec/lib/modules/ai_bot/playground_spec.rb +++ b/spec/lib/modules/ai_bot/playground_spec.rb @@ -3,16 +3,29 @@ RSpec.describe DiscourseAi::AiBot::Playground do subject(:playground) { described_class.new(bot) } - before do + fab!(:bot_user) do SiteSetting.ai_bot_enabled_chat_bots = "claude-2" SiteSetting.ai_bot_enabled = true + User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) end - let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) } - let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) } + fab!(:bot) do + persona = + AiPersona + .find( + DiscourseAi::AiBot::Personas::Persona.system_personas[ + DiscourseAi::AiBot::Personas::General + ], + ) + .class_instance + .new + DiscourseAi::AiBot::Bot.as(bot_user, persona: persona) + end - fab!(:user) { Fabricate(:user) } - let!(:pm) do + fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) } + + fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } + fab!(:pm) do Fabricate( :private_message_topic, title: "This is my special PM", @@ -23,13 +36,13 @@ RSpec.describe DiscourseAi::AiBot::Playground do ], ) end - let!(:first_post) do + fab!(:first_post) do Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") end - let!(:second_post) do + fab!(:second_post) do Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") end - let!(:third_post) do + fab!(:third_post) do Fabricate( :post, topic: pm, @@ -39,6 +52,93 @@ RSpec.describe DiscourseAi::AiBot::Playground do ) end + describe "persona with user support" do + before do + Jobs.run_immediately! + SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" + end + + fab!(:persona) do + persona = + AiPersona.create!( + name: "Test Persona", + description: "A test persona", + allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], + enabled: true, + system_prompt: "You are a helpful bot", + ) + + persona.create_user! + persona.update!(default_llm: "claude-2", mentionable: true) + persona + end + + it "replies to whispers with a whisper" do + post = nil + DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do + post = + create_post( + title: "My public topic", + raw: "Hey @#{persona.user.username}, can you help me?", + post_type: Post.types[:whisper], + ) + end + + post.topic.reload + last_post = post.topic.posts.order(:post_number).last + expect(last_post.raw).to eq("Yes I can") + expect(last_post.user_id).to eq(persona.user_id) + expect(last_post.post_type).to eq(Post.types[:whisper]) + end + + it "allows mentioning a persona" do + post = nil + DiscourseAi::Completions::Llm.with_prepared_responses(["Yes I can"]) do + post = + create_post( + title: "My public topic", + raw: "Hey @#{persona.user.username}, can you help me?", + ) + end + + post.topic.reload + last_post = post.topic.posts.order(:post_number).last + expect(last_post.raw).to eq("Yes I can") + expect(last_post.user_id).to eq(persona.user_id) + end + + it "picks the correct llm for persona in PMs" do + # If you start a PM with GPT 3.5 bot, replies should come from it, not from Claude + SiteSetting.ai_bot_enabled = true + SiteSetting.ai_bot_enabled_chat_bots = "gpt-3.5-turbo|claude-2" + + post = nil + gpt3_5_bot_user = User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) + + # title is queued first, ensures it uses the llm targeted via target_usernames not claude + DiscourseAi::Completions::Llm.with_prepared_responses( + ["Magic title", "Yes I can"], + llm: "open_ai:gpt-3.5-turbo-16k", + ) do + post = + create_post( + title: "I just made a PM", + raw: "Hey @#{persona.user.username}, can you help me?", + target_usernames: "#{user.username},#{gpt3_5_bot_user.username}", + archetype: Archetype.private_message, + user: admin, + ) + end + + last_post = post.topic.posts.order(:post_number).last + expect(last_post.raw).to eq("Yes I can") + expect(last_post.user_id).to eq(persona.user_id) + + last_post.topic.reload + expect(last_post.topic.allowed_users.pluck(:user_id)).to include(persona.user_id) + end + end + describe "#title_playground" do let(:expected_response) { "This is a suggested title" } @@ -112,7 +212,16 @@ RSpec.describe DiscourseAi::AiBot::Playground do context "with Dall E bot" do let(:bot) do - DiscourseAi::AiBot::Bot.as(bot_user, persona: DiscourseAi::AiBot::Personas::DallE3.new) + persona = + AiPersona + .find( + DiscourseAi::AiBot::Personas::Persona.system_personas[ + DiscourseAi::AiBot::Personas::DallE3 + ], + ) + .class_instance + .new + DiscourseAi::AiBot::Bot.as(bot_user, persona: persona) end it "does not include placeholders in conversation context (simulate DALL-E)" do @@ -155,6 +264,24 @@ RSpec.describe DiscourseAi::AiBot::Playground do end describe "#conversation_context" do + context "with limited context" do + before do + @old_persona = playground.bot.persona + persona = Fabricate(:ai_persona, max_context_posts: 1) + playground.bot.persona = persona.class_instance.new + end + + after { playground.bot.persona = @old_persona } + + it "respects max_context_post" do + context = playground.conversation_context(third_post) + + expect(context).to contain_exactly( + *[{ type: :user, id: user.username, content: third_post.raw }], + ) + end + end + it "includes previous posts ordered by post_number" do context = playground.conversation_context(third_post) diff --git a/spec/lib/modules/ai_bot/tools/random_picker_spec.rb b/spec/lib/modules/ai_bot/tools/random_picker_spec.rb new file mode 100644 index 00000000..80c24548 --- /dev/null +++ b/spec/lib/modules/ai_bot/tools/random_picker_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseAi::AiBot::Tools::RandomPicker do + describe "#invoke" do + subject { described_class.new({ options: options }).invoke(nil, nil) } + + context "with options as simple list of strings" do + let(:options) { %w[apple banana cherry] } + + it "returns one of the options" do + expect(options).to include(subject[:result]) + end + end + + context "with options as ranges" do + let(:options) { %w[1-3 10-20] } + + it "returns a number within one of the provided ranges" do + results = subject[:result] + expect(results).to all( + satisfy { |result| (1..3).include?(result) || (10..20).include?(result) }, + ) + end + end + + context "with options as comma-separated values" do + let(:options) { %w[red,green,blue mon,tue,wed] } + + it "returns one value from each comma-separated list" do + results = subject[:result] + expect(results).to include(a_kind_of(String)) + results.each { |result| expect(result.split(",")).to include(result) } + end + end + + context "with mixed options (list, range, and comma-separated)" do + let(:options) { %w[apple 1-3 mon,tue,wed] } + + it "handles each option appropriately" do + results = subject[:result] + expect(results.size).to eq(options.size) + # Verifying each type of option is respected needs a more elaborate setup, + # potentially mocking or specific expectations for each type. + end + end + + context "with an invalid format in options" do + let(:options) { ["invalid_format"] } + + it "returns an error message for invalid formats" do + expect(subject[:result]).to include("invalid_format") + end + end + end +end diff --git a/spec/models/ai_persona_spec.rb b/spec/models/ai_persona_spec.rb index 7e79c2d1..e159299d 100644 --- a/spec/models/ai_persona_spec.rb +++ b/spec/models/ai_persona_spec.rb @@ -1,6 +1,92 @@ # frozen_string_literal: true RSpec.describe AiPersona do + it "validates context settings" do + persona = + AiPersona.new( + name: "test", + description: "test", + system_prompt: "test", + commands: [], + allowed_group_ids: [], + ) + + expect(persona.valid?).to eq(true) + + persona.max_context_posts = 0 + expect(persona.valid?).to eq(false) + expect(persona.errors[:max_context_posts]).to eq(["must be greater than 0"]) + + persona.max_context_posts = 1 + expect(persona.valid?).to eq(true) + + persona.max_context_posts = nil + expect(persona.valid?).to eq(true) + end + + it "allows creation of user" do + persona = + AiPersona.create!( + name: "test", + description: "test", + system_prompt: "test", + commands: [], + allowed_group_ids: [], + ) + + user = persona.create_user! + expect(user.username).to eq("test_bot") + expect(user.name).to eq("Test") + expect(user.bot?).to be(true) + expect(user.id).to be <= AiPersona::FIRST_PERSONA_USER_ID + end + + it "defines singleton methods on system persona classes" do + forum_helper = AiPersona.find_by(name: "Forum Helper") + forum_helper.update!( + user_id: 1, + mentionable: true, + default_llm: "anthropic:claude-2", + max_context_posts: 3, + ) + + klass = forum_helper.class_instance + + expect(klass.id).to eq(forum_helper.id) + expect(klass.system).to eq(true) + # tl 0 by default + expect(klass.allowed_group_ids).to eq([10]) + expect(klass.user_id).to eq(1) + expect(klass.mentionable).to eq(true) + expect(klass.default_llm).to eq("anthropic:claude-2") + expect(klass.max_context_posts).to eq(3) + end + + it "defines singleton methods non persona classes" do + persona = + AiPersona.create!( + name: "test", + description: "test", + system_prompt: "test", + commands: [], + allowed_group_ids: [], + default_llm: "anthropic:claude-2", + max_context_posts: 3, + mentionable: true, + user_id: 1, + ) + + klass = persona.class_instance + + expect(klass.id).to eq(persona.id) + expect(klass.system).to eq(false) + expect(klass.allowed_group_ids).to eq([]) + expect(klass.user_id).to eq(1) + expect(klass.mentionable).to eq(true) + expect(klass.default_llm).to eq("anthropic:claude-2") + expect(klass.max_context_posts).to eq(3) + end + it "does not leak caches between sites" do AiPersona.create!( name: "pun_bot", diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb index 3136cc8f..02dbcee8 100644 --- a/spec/requests/admin/ai_personas_controller_spec.rb +++ b/spec/requests/admin/ai_personas_controller_spec.rb @@ -8,7 +8,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do describe "GET #index" do it "returns a success response" do - get "/admin/plugins/discourse-ai/ai_personas.json" + get "/admin/plugins/discourse-ai/ai-personas.json" expect(response).to be_successful expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count) @@ -17,6 +17,17 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do ) end + it "sideloads llms" do + get "/admin/plugins/discourse-ai/ai-personas.json" + expect(response).to be_successful + + expect(response.parsed_body["meta"]["llms"]).to eq( + DiscourseAi::Configuration::LlmEnumerator.values.map do |hash| + { "id" => hash[:value], "name" => hash[:name] } + end, + ) + end + it "returns commands options with each command" do persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"]) persona2 = @@ -24,14 +35,22 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do :ai_persona, name: "search2", commands: [["SearchCommand", { base_query: "test" }]], + mentionable: true, + default_llm: "anthropic:claude-2", ) + persona2.create_user! - get "/admin/plugins/discourse-ai/ai_personas.json" + 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 } + expect(serializer_persona2["mentionable"]).to eq(true) + expect(serializer_persona2["default_llm"]).to eq("anthropic:claude-2") + expect(serializer_persona2["user_id"]).to eq(persona2.user_id) + expect(serializer_persona2["user"]["id"]).to eq(persona2.user_id) + commands = response.parsed_body["meta"]["commands"] search_command = commands.find { |c| c["id"] == "Search" } @@ -86,7 +105,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "returns localized persona names and descriptions" do - get "/admin/plugins/discourse-ai/ai_personas.json" + get "/admin/plugins/discourse-ai/ai-personas.json" id = DiscourseAi::AiBot::Personas::Persona.system_personas[ @@ -102,7 +121,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do describe "GET #show" do it "returns a success response" do - get "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json" + get "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" expect(response).to be_successful expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name) end @@ -118,12 +137,14 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do commands: [["search", { "base_query" => "test" }]], top_p: 0.1, temperature: 0.5, + mentionable: true, + default_llm: "anthropic:claude-2", } end it "creates a new AiPersona" do expect { - post "/admin/plugins/discourse-ai/ai_personas.json", + post "/admin/plugins/discourse-ai/ai-personas.json", params: { ai_persona: valid_attributes }.to_json, headers: { "CONTENT_TYPE" => "application/json", @@ -134,6 +155,8 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do expect(persona_json["name"]).to eq("superbot") expect(persona_json["top_p"]).to eq(0.1) expect(persona_json["temperature"]).to eq(0.5) + expect(persona_json["mentionable"]).to eq(true) + expect(persona_json["default_llm"]).to eq("anthropic:claude-2") persona = AiPersona.find(persona_json["id"]) @@ -146,18 +169,27 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with invalid params" do it "renders a JSON response with errors for the new ai_persona" do - post "/admin/plugins/discourse-ai/ai_personas.json", params: { ai_persona: { foo: "" } } # invalid attribute + post "/admin/plugins/discourse-ai/ai-personas.json", params: { ai_persona: { foo: "" } } # invalid attribute expect(response).to have_http_status(:unprocessable_entity) expect(response.content_type).to include("application/json") end end end + describe "POST #create_user" do + it "creates a user for the persona" do + post "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}/create-user.json" + ai_persona.reload + + expect(response).to be_successful + expect(response.parsed_body["user"]["id"]).to eq(ai_persona.user_id) + end + end + describe "PUT #update" do it "allows us to trivially clear top_p and temperature" do persona = Fabricate(:ai_persona, name: "test_bot2", top_p: 0.5, temperature: 0.1) - - put "/admin/plugins/discourse-ai/ai_personas/#{persona.id}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", params: { ai_persona: { top_p: "", @@ -173,7 +205,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow temperature and top p changes on stock personas" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { top_p: 0.5, @@ -186,7 +218,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with valid params" do it "updates the requested ai_persona" do - put "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", params: { ai_persona: { name: "SuperBot", @@ -207,7 +239,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with system personas" do it "does not allow editing of system prompts" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { system_prompt: "you are not a helpful bot", @@ -220,7 +252,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow editing of commands" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { commands: %w[SearchCommand ImageCommand], @@ -233,7 +265,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does not allow editing of name and description cause it is localized" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { name: "bob", @@ -247,7 +279,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do end it "does allow some actions" do - put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json", params: { ai_persona: { allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]], @@ -262,7 +294,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do context "with invalid params" do it "renders a JSON response with errors for the ai_persona" do - put "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json", + put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json", params: { ai_persona: { name: "", @@ -277,7 +309,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do describe "DELETE #destroy" do it "destroys the requested ai_persona" do expect { - delete "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json" + delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" expect(response).to have_http_status(:no_content) }.to change(AiPersona, :count).by(-1) @@ -285,7 +317,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do it "is not allowed to delete system personas" do expect { - delete "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json" + delete "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json" expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body["errors"].join).not_to be_blank # let's make sure this is translated diff --git a/spec/system/ai_bot/persona_spec.rb b/spec/system/ai_bot/persona_spec.rb index b296c515..bbaabee1 100644 --- a/spec/system/ai_bot/persona_spec.rb +++ b/spec/system/ai_bot/persona_spec.rb @@ -30,7 +30,7 @@ RSpec.describe "AI personas", type: :system, js: true do end it "allows creation of a persona" do - visit "/admin/plugins/discourse-ai/ai_personas" + visit "/admin/plugins/discourse-ai/ai-personas" find(".ai-persona-list-editor__header .btn-primary").click() find(".ai-persona-editor__name").set("Test Persona") find(".ai-persona-editor__description").fill_in(with: "I am a test persona") @@ -42,7 +42,7 @@ RSpec.describe "AI personas", type: :system, js: true do find(".ai-persona-editor__save").click() - 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") persona_id = page.current_path.split("/").last.to_i @@ -54,7 +54,7 @@ RSpec.describe "AI personas", type: :system, js: true do end 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}" + visit "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}" expect(page).not_to have_selector(".ai-persona-editor__delete") expect(find(".ai-persona-editor__system_prompt")).to be_disabled end @@ -62,7 +62,7 @@ RSpec.describe "AI personas", type: :system, js: true do it "will enable persona right away when you click on enable but does not save side effects" do persona = Fabricate(:ai_persona, enabled: false) - visit "/admin/plugins/discourse-ai/ai_personas/#{persona.id}" + visit "/admin/plugins/discourse-ai/ai-personas/#{persona.id}" find(".ai-persona-editor__name").set("Test Persona 1") PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle diff --git a/test/javascripts/unit/models/ai-persona-test.js b/test/javascripts/unit/models/ai-persona-test.js index 84ba05af..a83bf2e9 100644 --- a/test/javascripts/unit/models/ai-persona-test.js +++ b/test/javascripts/unit/models/ai-persona-test.js @@ -41,6 +41,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () { description: "Description", top_p: 0.8, temperature: 0.7, + mentionable: false, + default_llm: "Default LLM", + user: null, + user_id: null, + max_context_posts: 5, }; const aiPersona = AiPersona.create({ ...properties }); @@ -67,6 +72,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () { description: "Description", top_p: 0.8, temperature: 0.7, + user: null, + user_id: null, + default_llm: "Default LLM", + mentionable: false, + max_context_posts: 5, }; const aiPersona = AiPersona.create({ ...properties });