FEATURE: mentionable personas and random picker tool, context limits (#466)
1. Personas are now optionally mentionable, meaning that you can mention them either from public topics or PMs - Mentioning from PMs helps "switch" persona mid conversation, meaning if you want to look up sites setting you can invoke the site setting bot, or if you want to generate an image you can invoke dall e - Mentioning outside of PMs allows you to inject a bot reply in a topic trivially - We also add the support for max_context_posts this allow you to limit the amount of context you feed in, which can help control costs 2. Add support for a "random picker" tool that can be used to pick random numbers 3. Clean up routing ai_personas -> ai-personas 4. Add Max Context Posts so users can control how much history a persona can consume (this is important for mentionable personas) Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
parent
33164a0fec
commit
3a8d95f6b2
|
@ -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: [],
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
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
|
||||
#
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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] ||= {};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
|
@ -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"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.editingModel.user}}
|
||||
<div class="control-group ai-persona-editor__mentionable">
|
||||
<DToggleSwitch
|
||||
class="ai-persona-editor__mentionable_toggle"
|
||||
@state={{@model.mentionable}}
|
||||
@label="discourse_ai.ai_persona.mentionable"
|
||||
{{on "click" this.toggleMentionable}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.mentionable_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
||||
<Input
|
||||
|
@ -187,6 +237,46 @@ export default class PersonaEditor extends Component {
|
|||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.default_llm"}}</label>
|
||||
<AiLlmSelector
|
||||
class="ai-persona-editor__llms"
|
||||
@value={{this.mappedDefaultLlm}}
|
||||
@llms={{@personas.resultSetMeta.llms}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.default_llm_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#unless @model.isNew}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.user"}}</label>
|
||||
{{#if this.editingModel.user}}
|
||||
<a
|
||||
class="avatar"
|
||||
href={{this.editingModel.user.path}}
|
||||
data-user-card={{this.editingModel.user.username}}
|
||||
>
|
||||
{{Avatar this.editingModel.user.avatar_template "small"}}
|
||||
</a>
|
||||
<LinkTo @route="adminUser" @model={{this.adminUser}}>
|
||||
{{this.editingModel.user.username}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<DButton
|
||||
@action={{this.createUser}}
|
||||
class="ai-persona-editor__create-user"
|
||||
>
|
||||
{{I18n.t "discourse_ai.ai_persona.create_user"}}
|
||||
</DButton>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.create_user_help"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
|
||||
<AiCommandSelector
|
||||
|
@ -221,6 +311,18 @@ export default class PersonaEditor extends Component {
|
|||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.max_context_posts"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
class="ai-persona-editor__max_context_posts"
|
||||
@value={{this.editingModel.max_context_posts}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.max_context_posts_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.showTemperature}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: <a href='%{url}'>%{title}</a>"
|
||||
time: "Time in %{timezone} is %{time}"
|
||||
summarize: "Summarized <a href='%{url}'>%{title}</a>"
|
||||
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -61,6 +61,7 @@ module DiscourseAi
|
|||
Tools::SearchSettings,
|
||||
Tools::Summarize,
|
||||
Tools::SettingContext,
|
||||
Tools::RandomPicker,
|
||||
]
|
||||
|
||||
tools << Tools::ListTags if SiteSetting.tagging_enabled
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
|
|
Loading…
Reference in New Issue