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 DiscourseAi
|
||||||
module Admin
|
module Admin
|
||||||
class AiPersonasController < ::Admin::AdminController
|
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
|
def index
|
||||||
ai_personas =
|
ai_personas =
|
||||||
|
@ -16,7 +16,11 @@ module DiscourseAi
|
||||||
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
|
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
|
||||||
AiToolSerializer.new(tool, root: false)
|
AiToolSerializer.new(tool, root: false)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@ -32,6 +36,11 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_user
|
||||||
|
user = @ai_persona.create_user!
|
||||||
|
render json: BasicUserSerializer.new(user, root: "user")
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @ai_persona.update(ai_persona_params)
|
if @ai_persona.update(ai_persona_params)
|
||||||
render json: @ai_persona
|
render json: @ai_persona
|
||||||
|
@ -64,6 +73,10 @@ module DiscourseAi
|
||||||
:priority,
|
:priority,
|
||||||
:top_p,
|
:top_p,
|
||||||
:temperature,
|
:temperature,
|
||||||
|
:default_llm,
|
||||||
|
:user_id,
|
||||||
|
:mentionable,
|
||||||
|
:max_context_posts,
|
||||||
allowed_group_ids: [],
|
allowed_group_ids: [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -7,22 +7,11 @@ module ::Jobs
|
||||||
def execute(args)
|
def execute(args)
|
||||||
return unless bot_user = User.find_by(id: args[:bot_user_id])
|
return unless bot_user = User.find_by(id: args[:bot_user_id])
|
||||||
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||||
|
persona_id = args[:persona_id]
|
||||||
|
|
||||||
begin
|
begin
|
||||||
persona = nil
|
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
|
||||||
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?
|
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
|
|
||||||
|
|
||||||
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
|
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 :description, presence: true, length: { maximum: 2000 }
|
||||||
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
||||||
validate :system_persona_unchangeable, on: :update, if: :system
|
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
|
before_destroy :ensure_not_system
|
||||||
|
|
||||||
|
@ -56,6 +60,23 @@ class AiPersona < ActiveRecord::Base
|
||||||
.map(&:class_instance)
|
.map(&:class_instance)
|
||||||
end
|
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
|
after_commit :bump_cache
|
||||||
|
|
||||||
def bump_cache
|
def bump_cache
|
||||||
|
@ -66,6 +87,10 @@ class AiPersona < ActiveRecord::Base
|
||||||
allowed_group_ids = self.allowed_group_ids
|
allowed_group_ids = self.allowed_group_ids
|
||||||
id = self.id
|
id = self.id
|
||||||
system = self.system
|
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]
|
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
|
||||||
if persona_class
|
if persona_class
|
||||||
|
@ -81,6 +106,22 @@ class AiPersona < ActiveRecord::Base
|
||||||
system
|
system
|
||||||
end
|
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
|
return persona_class
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -124,6 +165,10 @@ class AiPersona < ActiveRecord::Base
|
||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
define_singleton_method :user_id do
|
||||||
|
user_id
|
||||||
|
end
|
||||||
|
|
||||||
define_singleton_method :description do
|
define_singleton_method :description do
|
||||||
description
|
description
|
||||||
end
|
end
|
||||||
|
@ -136,6 +181,22 @@ class AiPersona < ActiveRecord::Base
|
||||||
allowed_group_ids
|
allowed_group_ids
|
||||||
end
|
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
|
define_singleton_method :to_s do
|
||||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||||
end
|
end
|
||||||
|
@ -171,6 +232,45 @@ class AiPersona < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def system_persona_unchangeable
|
def system_persona_unchangeable
|
||||||
|
@ -206,6 +306,12 @@ end
|
||||||
# priority :boolean default(FALSE), not null
|
# priority :boolean default(FALSE), not null
|
||||||
# temperature :float
|
# temperature :float
|
||||||
# top_p :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
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -13,7 +13,13 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||||
:system_prompt,
|
:system_prompt,
|
||||||
:allowed_group_ids,
|
:allowed_group_ids,
|
||||||
:temperature,
|
:temperature,
|
||||||
:top_p
|
:top_p,
|
||||||
|
:mentionable,
|
||||||
|
:default_llm,
|
||||||
|
:user_id,
|
||||||
|
:max_context_posts
|
||||||
|
|
||||||
|
has_one :user, serializer: BasicUserSerializer, embed: :object
|
||||||
|
|
||||||
def name
|
def name
|
||||||
object.class_instance.name
|
object.class_instance.name
|
||||||
|
|
|
@ -5,7 +5,7 @@ export default {
|
||||||
|
|
||||||
map() {
|
map() {
|
||||||
this.route("discourse-ai", function () {
|
this.route("discourse-ai", function () {
|
||||||
this.route("ai-personas", { path: "ai_personas" }, function () {
|
this.route("ai-personas", function () {
|
||||||
this.route("new");
|
this.route("new");
|
||||||
this.route("show", { path: "/:id" });
|
this.route("show", { path: "/:id" });
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,8 +7,12 @@ export default class Adapter extends RestAdapter {
|
||||||
return "/admin/plugins/discourse-ai/";
|
return "/admin/plugins/discourse-ai/";
|
||||||
}
|
}
|
||||||
|
|
||||||
pathFor() {
|
pathFor(store, type, findArgs) {
|
||||||
return super.pathFor(...arguments) + ".json";
|
// 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() {
|
apiNameFor() {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import RestModel from "discourse/models/rest";
|
import RestModel from "discourse/models/rest";
|
||||||
|
|
||||||
const ATTRIBUTES = [
|
const ATTRIBUTES = [
|
||||||
|
@ -12,6 +13,11 @@ const ATTRIBUTES = [
|
||||||
"priority",
|
"priority",
|
||||||
"top_p",
|
"top_p",
|
||||||
"temperature",
|
"temperature",
|
||||||
|
"user_id",
|
||||||
|
"mentionable",
|
||||||
|
"default_llm",
|
||||||
|
"user",
|
||||||
|
"max_context_posts",
|
||||||
];
|
];
|
||||||
|
|
||||||
class CommandOption {
|
class CommandOption {
|
||||||
|
@ -45,6 +51,18 @@ export default class AiPersona extends RestModel {
|
||||||
this.commands = properties.commands;
|
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) {
|
getCommandOption(commandId, optionId) {
|
||||||
this.commandOptions ||= {};
|
this.commandOptions ||= {};
|
||||||
this.commandOptions[commandId] ||= {};
|
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 { action } from "@ember/object";
|
||||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||||
|
import { LinkTo } from "@ember/routing";
|
||||||
import { later } from "@ember/runloop";
|
import { later } from "@ember/runloop";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import Textarea from "discourse/components/d-textarea";
|
import Textarea from "discourse/components/d-textarea";
|
||||||
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
import DToggleSwitch from "discourse/components/d-toggle-switch";
|
||||||
|
import Avatar from "discourse/helpers/bound-avatar-template";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import Group from "discourse/models/group";
|
import Group from "discourse/models/group";
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
|
import AdminUser from "admin/models/admin-user";
|
||||||
import GroupChooser from "select-kit/components/group-chooser";
|
import GroupChooser from "select-kit/components/group-chooser";
|
||||||
import DTooltip from "float-kit/components/d-tooltip";
|
import DTooltip from "float-kit/components/d-tooltip";
|
||||||
import AiCommandSelector from "./ai-command-selector";
|
import AiCommandSelector from "./ai-command-selector";
|
||||||
|
import AiLlmSelector from "./ai-llm-selector";
|
||||||
import AiPersonaCommandOptions from "./ai-persona-command-options";
|
import AiPersonaCommandOptions from "./ai-persona-command-options";
|
||||||
|
|
||||||
export default class PersonaEditor extends Component {
|
export default class PersonaEditor extends Component {
|
||||||
|
@ -81,6 +85,22 @@ export default class PersonaEditor extends Component {
|
||||||
return this.editingModel?.top_p || !this.editingModel?.system;
|
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
|
@action
|
||||||
delete() {
|
delete() {
|
||||||
return this.dialog.confirm({
|
return this.dialog.confirm({
|
||||||
|
@ -103,26 +123,42 @@ export default class PersonaEditor extends Component {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async toggleEnabled() {
|
async toggleEnabled() {
|
||||||
this.args.model.set("enabled", !this.args.model.enabled);
|
await this.toggleField("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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async togglePriority() {
|
async togglePriority() {
|
||||||
this.args.model.set("priority", !this.args.model.priority);
|
await this.toggleField("priority", true);
|
||||||
this.editingModel.set("priority", this.args.model.priority);
|
}
|
||||||
|
|
||||||
|
@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) {
|
if (!this.args.model.isNew) {
|
||||||
try {
|
try {
|
||||||
await this.args.model.update({ priority: this.args.model.priority });
|
const args = {};
|
||||||
|
args[field] = this.args.model[field];
|
||||||
|
|
||||||
|
await this.args.model.update(args);
|
||||||
|
if (sortPersonas) {
|
||||||
this.#sortPersonas();
|
this.#sortPersonas();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
popupAjaxError(e);
|
popupAjaxError(e);
|
||||||
}
|
}
|
||||||
|
@ -170,6 +206,20 @@ export default class PersonaEditor extends Component {
|
||||||
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
|
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="control-group">
|
||||||
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
||||||
<Input
|
<Input
|
||||||
|
@ -187,6 +237,46 @@ export default class PersonaEditor extends Component {
|
||||||
disabled={{this.editingModel.system}}
|
disabled={{this.editingModel.system}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="control-group">
|
||||||
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
|
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
|
||||||
<AiCommandSelector
|
<AiCommandSelector
|
||||||
|
@ -221,6 +311,18 @@ export default class PersonaEditor extends Component {
|
||||||
disabled={{this.editingModel.system}}
|
disabled={{this.editingModel.system}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}}
|
{{#if this.showTemperature}}
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>
|
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>
|
||||||
|
|
|
@ -31,6 +31,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-persona-editor {
|
.ai-persona-editor {
|
||||||
|
.fk-d-tooltip__icon {
|
||||||
|
padding-left: 0.25em;
|
||||||
|
color: var(--primary-medium);
|
||||||
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
@ -53,10 +57,9 @@
|
||||||
&__priority {
|
&__priority {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
.fk-d-tooltip__icon {
|
&__mentionable {
|
||||||
padding-left: 0.25em;
|
display: flex;
|
||||||
color: var(--primary-medium);
|
align-items: center;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,6 +108,16 @@ en:
|
||||||
ai_persona:
|
ai_persona:
|
||||||
name: Name
|
name: Name
|
||||||
description: Description
|
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
|
system_prompt: System Prompt
|
||||||
save: Save
|
save: Save
|
||||||
saved: AI Persona Saved
|
saved: AI Persona Saved
|
||||||
|
|
|
@ -182,6 +182,7 @@ en:
|
||||||
name: "Base Search Query"
|
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."
|
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:
|
command_summary:
|
||||||
|
random_picker: "Random Picker"
|
||||||
categories: "List categories"
|
categories: "List categories"
|
||||||
search: "Search"
|
search: "Search"
|
||||||
tags: "List tags"
|
tags: "List tags"
|
||||||
|
@ -195,6 +196,7 @@ en:
|
||||||
search_settings: "Searching site settings"
|
search_settings: "Searching site settings"
|
||||||
dall_e: "Generate image"
|
dall_e: "Generate image"
|
||||||
command_help:
|
command_help:
|
||||||
|
random_picker: "Pick a random number or a random element of a list"
|
||||||
categories: "List all publicly visible categories on the forum"
|
categories: "List all publicly visible categories on the forum"
|
||||||
search: "Search all public topics on the forum"
|
search: "Search all public topics on the forum"
|
||||||
tags: "List all tags on the forum"
|
tags: "List all tags on the forum"
|
||||||
|
@ -208,6 +210,7 @@ en:
|
||||||
search_settings: "Search site settings"
|
search_settings: "Search site settings"
|
||||||
dall_e: "Generate image using DALL-E 3"
|
dall_e: "Generate image using DALL-E 3"
|
||||||
command_description:
|
command_description:
|
||||||
|
random_picker: "Picking from %{options}, picked: %{result}"
|
||||||
read: "Reading: <a href='%{url}'>%{title}</a>"
|
read: "Reading: <a href='%{url}'>%{title}</a>"
|
||||||
time: "Time in %{timezone} is %{time}"
|
time: "Time in %{timezone} is %{time}"
|
||||||
summarize: "Summarized <a href='%{url}'>%{title}</a>"
|
summarize: "Summarized <a href='%{url}'>%{title}</a>"
|
||||||
|
|
|
@ -27,9 +27,13 @@ Discourse::Application.routes.draw do
|
||||||
:constraints => StaffConstraint.new
|
:constraints => StaffConstraint.new
|
||||||
|
|
||||||
scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do
|
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,
|
resources :ai_personas,
|
||||||
only: %i[index create show update destroy],
|
only: %i[index create show update destroy],
|
||||||
|
path: "ai-personas",
|
||||||
controller: "discourse_ai/admin/ai_personas"
|
controller: "discourse_ai/admin/ai_personas"
|
||||||
|
|
||||||
|
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
attr_reader :bot_user
|
attr_reader :bot_user
|
||||||
|
attr_accessor :persona
|
||||||
|
|
||||||
def get_updated_title(conversation_context, post_user)
|
def get_updated_title(conversation_context, post_user)
|
||||||
system_insts = <<~TEXT.strip
|
system_insts = <<~TEXT.strip
|
||||||
|
@ -111,8 +112,6 @@ module DiscourseAi
|
||||||
raw_context
|
raw_context
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :persona
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def invoke_tool(tool, llm, cancel, &update_blk)
|
def invoke_tool(tool, llm, cancel, &update_blk)
|
||||||
|
|
|
@ -23,6 +23,8 @@ module DiscourseAi
|
||||||
[FAKE_ID, "fake_bot", "fake"],
|
[FAKE_ID, "fake_bot", "fake"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
BOT_USER_IDS = BOTS.map(&:first)
|
||||||
|
|
||||||
def self.map_bot_model_to_user_id(model_name)
|
def self.map_bot_model_to_user_id(model_name)
|
||||||
case model_name
|
case model_name
|
||||||
in "gpt-4-turbo"
|
in "gpt-4-turbo"
|
||||||
|
@ -111,16 +113,7 @@ module DiscourseAi
|
||||||
name || topic.custom_fields["ai_persona"]
|
name || topic.custom_fields["ai_persona"]
|
||||||
end
|
end
|
||||||
|
|
||||||
plugin.on(:post_created) do |post|
|
plugin.on(:post_created) { |post| DiscourseAi::AiBot::Playground.schedule_reply(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
|
|
||||||
|
|
||||||
if plugin.respond_to?(:register_editable_topic_custom_field)
|
if plugin.respond_to?(:register_editable_topic_custom_field)
|
||||||
plugin.register_editable_topic_custom_field(:ai_persona_id)
|
plugin.register_editable_topic_custom_field(:ai_persona_id)
|
||||||
|
|
|
@ -61,6 +61,7 @@ module DiscourseAi
|
||||||
Tools::SearchSettings,
|
Tools::SearchSettings,
|
||||||
Tools::Summarize,
|
Tools::Summarize,
|
||||||
Tools::SettingContext,
|
Tools::SettingContext,
|
||||||
|
Tools::RandomPicker,
|
||||||
]
|
]
|
||||||
|
|
||||||
tools << Tools::ListTags if SiteSetting.tagging_enabled
|
tools << Tools::ListTags if SiteSetting.tagging_enabled
|
||||||
|
|
|
@ -3,26 +3,101 @@
|
||||||
module DiscourseAi
|
module DiscourseAi
|
||||||
module AiBot
|
module AiBot
|
||||||
class Playground
|
class Playground
|
||||||
|
attr_reader :bot
|
||||||
|
|
||||||
# An abstraction to manage the bot and topic interactions.
|
# An abstraction to manage the bot and topic interactions.
|
||||||
# The bot will take care of completions while this class updates the topic title
|
# The bot will take care of completions while this class updates the topic title
|
||||||
# and stream replies.
|
# and stream replies.
|
||||||
|
|
||||||
REQUIRE_TITLE_UPDATE = "discourse-ai-title-update"
|
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)
|
def initialize(bot)
|
||||||
@bot = bot
|
@bot = bot
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_playground_with(post)
|
def update_playground_with(post)
|
||||||
if can_attach?(post) && bot.bot_user
|
if can_attach?(post)
|
||||||
schedule_playground_titling(post, bot.bot_user)
|
schedule_playground_titling(post)
|
||||||
schedule_bot_reply(post, bot.bot_user)
|
schedule_bot_reply(post)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_context(post)
|
def conversation_context(post)
|
||||||
# Pay attention to the `post_number <= ?` here.
|
# Pay attention to the `post_number <= ?` here.
|
||||||
# We want to inject the last post as context because they are translated differently.
|
# 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 =
|
context =
|
||||||
post
|
post
|
||||||
.topic
|
.topic
|
||||||
|
@ -31,8 +106,8 @@ module DiscourseAi
|
||||||
.joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id")
|
.joins("LEFT JOIN post_custom_prompts ON post_custom_prompts.post_id = posts.id")
|
||||||
.where("post_number <= ?", post.post_number)
|
.where("post_number <= ?", post.post_number)
|
||||||
.order("post_number desc")
|
.order("post_number desc")
|
||||||
.where("post_type = ?", Post.types[:regular])
|
.where("post_type in (?)", post_types)
|
||||||
.limit(50)
|
.limit(max_posts)
|
||||||
.pluck(:raw, :username, "post_custom_prompts.custom_prompt")
|
.pluck(:raw, :username, "post_custom_prompts.custom_prompt")
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
@ -96,6 +171,9 @@ module DiscourseAi
|
||||||
reply = +""
|
reply = +""
|
||||||
start = Time.now
|
start = Time.now
|
||||||
|
|
||||||
|
post_type =
|
||||||
|
post.post_type == Post.types[:whisper] ? Post.types[:whisper] : Post.types[:regular]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
site_url: Discourse.base_url,
|
site_url: Discourse.base_url,
|
||||||
site_title: SiteSetting.title,
|
site_title: SiteSetting.title,
|
||||||
|
@ -106,19 +184,36 @@ module DiscourseAi
|
||||||
user: post.user,
|
user: post.user,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
stream_reply = post.topic.private_message?
|
||||||
|
|
||||||
|
# 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 =
|
reply_post =
|
||||||
PostCreator.create!(
|
PostCreator.create!(
|
||||||
bot.bot_user,
|
reply_user,
|
||||||
topic_id: post.topic_id,
|
topic_id: post.topic_id,
|
||||||
raw: "",
|
raw: "",
|
||||||
skip_validations: true,
|
skip_validations: true,
|
||||||
skip_jobs: true,
|
skip_jobs: true,
|
||||||
|
post_type: post_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
publish_update(reply_post, { raw: reply_post.cooked })
|
publish_update(reply_post, { raw: reply_post.cooked })
|
||||||
|
|
||||||
redis_stream_key = "gpt_cancel:#{reply_post.id}"
|
redis_stream_key = "gpt_cancel:#{reply_post.id}"
|
||||||
Discourse.redis.setex(redis_stream_key, 60, 1)
|
Discourse.redis.setex(redis_stream_key, 60, 1)
|
||||||
|
end
|
||||||
|
|
||||||
new_custom_prompts =
|
new_custom_prompts =
|
||||||
bot.reply(context) do |partial, cancel, placeholder|
|
bot.reply(context) do |partial, cancel, placeholder|
|
||||||
|
@ -126,12 +221,12 @@ module DiscourseAi
|
||||||
raw = reply.dup
|
raw = reply.dup
|
||||||
raw << "\n\n" << placeholder if placeholder.present?
|
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
|
cancel&.call
|
||||||
|
|
||||||
reply_post.update!(raw: reply, cooked: PrettyText.cook(reply))
|
reply_post.update!(raw: reply, cooked: PrettyText.cook(reply))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if stream_reply
|
||||||
# Minor hack to skip the delay during tests.
|
# Minor hack to skip the delay during tests.
|
||||||
if placeholder.blank?
|
if placeholder.blank?
|
||||||
next if (Time.now - start < 0.5) && !Rails.env.test?
|
next if (Time.now - start < 0.5) && !Rails.env.test?
|
||||||
|
@ -142,14 +237,31 @@ module DiscourseAi
|
||||||
|
|
||||||
publish_update(reply_post, { raw: raw })
|
publish_update(reply_post, { raw: raw })
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return if reply.blank?
|
return if reply.blank?
|
||||||
|
|
||||||
|
if stream_reply
|
||||||
# land the final message prior to saving so we don't clash
|
# land the final message prior to saving so we don't clash
|
||||||
reply_post.cooked = PrettyText.cook(reply)
|
reply_post.cooked = PrettyText.cook(reply)
|
||||||
publish_final_update(reply_post)
|
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
|
# not need to add a custom prompt for a single reply
|
||||||
if new_custom_prompts.length > 1
|
if new_custom_prompts.length > 1
|
||||||
|
@ -161,7 +273,7 @@ module DiscourseAi
|
||||||
|
|
||||||
reply_post
|
reply_post
|
||||||
ensure
|
ensure
|
||||||
publish_final_update(reply_post)
|
publish_final_update(reply_post) if stream_reply
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -179,33 +291,38 @@ module DiscourseAi
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :bot
|
|
||||||
|
|
||||||
def can_attach?(post)
|
def can_attach?(post)
|
||||||
return false if bot.bot_user.nil?
|
return false if bot.bot_user.nil?
|
||||||
return false if post.post_type != Post.types[:regular]
|
return false if post.topic.private_message? && post.post_type != Post.types[:regular]
|
||||||
return false unless post.topic.private_message?
|
|
||||||
return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank?
|
return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank?
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def schedule_playground_titling(post, bot_user)
|
def schedule_playground_titling(post)
|
||||||
if post.post_number == 1
|
if post.post_number == 1 && post.topic.private_message?
|
||||||
post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true
|
post.topic.custom_fields[REQUIRE_TITLE_UPDATE] = true
|
||||||
post.topic.save_custom_fields
|
post.topic.save_custom_fields
|
||||||
end
|
|
||||||
|
|
||||||
::Jobs.enqueue_in(
|
::Jobs.enqueue_in(
|
||||||
5.minutes,
|
5.minutes,
|
||||||
:update_ai_bot_pm_title,
|
:update_ai_bot_pm_title,
|
||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
bot_user_id: bot_user.id,
|
bot_user_id: bot.bot_user.id,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def schedule_bot_reply(post, bot_user)
|
def schedule_bot_reply(post)
|
||||||
::Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_user.id)
|
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
|
end
|
||||||
|
|
||||||
def context(topic)
|
def context(topic)
|
||||||
|
|
|
@ -4,11 +4,11 @@ module DiscourseAi::AiBot::SiteSettingsExtension
|
||||||
def self.enable_or_disable_ai_bots
|
def self.enable_or_disable_ai_bots
|
||||||
enabled_bots = SiteSetting.ai_bot_enabled_chat_bots_map
|
enabled_bots = SiteSetting.ai_bot_enabled_chat_bots_map
|
||||||
enabled_bots = [] if !SiteSetting.ai_bot_enabled
|
enabled_bots = [] if !SiteSetting.ai_bot_enabled
|
||||||
|
|
||||||
DiscourseAi::AiBot::EntryPoint::BOTS.each do |id, bot_name, name|
|
DiscourseAi::AiBot::EntryPoint::BOTS.each do |id, bot_name, name|
|
||||||
if id == DiscourseAi::AiBot::EntryPoint::FAKE_ID
|
if id == DiscourseAi::AiBot::EntryPoint::FAKE_ID
|
||||||
next if Rails.env.production?
|
next if Rails.env.production?
|
||||||
end
|
end
|
||||||
|
|
||||||
active = enabled_bots.include?(name)
|
active = enabled_bots.include?(name)
|
||||||
user = User.find_by(id: id)
|
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,6 +21,7 @@ module DiscourseAi
|
||||||
def models_by_provider
|
def models_by_provider
|
||||||
# ChatGPT models are listed under open_ai but they are actually available through OpenAI and Azure.
|
# 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.
|
# However, since they use the same URL/key settings, there's no reason to duplicate them.
|
||||||
|
@models_by_provider ||=
|
||||||
{
|
{
|
||||||
aws_bedrock: %w[claude-instant-1 claude-2],
|
aws_bedrock: %w[claude-instant-1 claude-2],
|
||||||
anthropic: %w[claude-instant-1 claude-2],
|
anthropic: %w[claude-instant-1 claude-2],
|
||||||
|
@ -45,13 +46,25 @@ module DiscourseAi
|
||||||
}.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? }
|
}.tap { |h| h[:fake] = ["fake"] if Rails.env.test? || Rails.env.development? }
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_prepared_responses(responses)
|
def valid_provider_models
|
||||||
@canned_response = DiscourseAi::Completions::Endpoints::CannedResponse.new(responses)
|
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
|
ensure
|
||||||
# Don't leak prepared response if there's an exception.
|
# Don't leak prepared response if there's an exception.
|
||||||
@canned_response = nil
|
@canned_response = nil
|
||||||
|
@canned_llm = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def proxy(model_name)
|
def proxy(model_name)
|
||||||
|
@ -63,7 +76,12 @@ module DiscourseAi
|
||||||
dialect_klass =
|
dialect_klass =
|
||||||
DiscourseAi::Completions::Dialects::Dialect.dialect_for(model_name_without_prov)
|
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 =
|
gateway =
|
||||||
DiscourseAi::Completions::Endpoints::Base.endpoint_for(
|
DiscourseAi::Completions::Endpoints::Base.endpoint_for(
|
||||||
|
|
|
@ -14,10 +14,12 @@ RSpec.describe Jobs::CreateAiReply do
|
||||||
before { SiteSetting.min_personal_message_post_length = 5 }
|
before { SiteSetting.min_personal_message_post_length = 5 }
|
||||||
|
|
||||||
it "adds a reply from the bot" do
|
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
|
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
|
||||||
subject.execute(
|
subject.execute(
|
||||||
post_id: topic.first_post.id,
|
post_id: topic.first_post.id,
|
||||||
bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID,
|
bot_user_id: DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID,
|
||||||
|
persona_id: persona_id,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,16 +3,29 @@
|
||||||
RSpec.describe DiscourseAi::AiBot::Playground do
|
RSpec.describe DiscourseAi::AiBot::Playground do
|
||||||
subject(:playground) { described_class.new(bot) }
|
subject(:playground) { described_class.new(bot) }
|
||||||
|
|
||||||
before do
|
fab!(:bot_user) do
|
||||||
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
|
SiteSetting.ai_bot_enabled_chat_bots = "claude-2"
|
||||||
SiteSetting.ai_bot_enabled = true
|
SiteSetting.ai_bot_enabled = true
|
||||||
|
User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID) }
|
fab!(:bot) do
|
||||||
let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) }
|
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) }
|
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
|
||||||
let!(:pm) do
|
|
||||||
|
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
||||||
|
fab!(:pm) do
|
||||||
Fabricate(
|
Fabricate(
|
||||||
:private_message_topic,
|
:private_message_topic,
|
||||||
title: "This is my special PM",
|
title: "This is my special PM",
|
||||||
|
@ -23,13 +36,13 @@ RSpec.describe DiscourseAi::AiBot::Playground do
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
end
|
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")
|
Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user")
|
||||||
end
|
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")
|
Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply")
|
||||||
end
|
end
|
||||||
let!(:third_post) do
|
fab!(:third_post) do
|
||||||
Fabricate(
|
Fabricate(
|
||||||
:post,
|
:post,
|
||||||
topic: pm,
|
topic: pm,
|
||||||
|
@ -39,6 +52,93 @@ RSpec.describe DiscourseAi::AiBot::Playground do
|
||||||
)
|
)
|
||||||
end
|
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
|
describe "#title_playground" do
|
||||||
let(:expected_response) { "This is a suggested title" }
|
let(:expected_response) { "This is a suggested title" }
|
||||||
|
|
||||||
|
@ -112,7 +212,16 @@ RSpec.describe DiscourseAi::AiBot::Playground do
|
||||||
|
|
||||||
context "with Dall E bot" do
|
context "with Dall E bot" do
|
||||||
let(: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
|
end
|
||||||
|
|
||||||
it "does not include placeholders in conversation context (simulate DALL-E)" do
|
it "does not include placeholders in conversation context (simulate DALL-E)" do
|
||||||
|
@ -155,6 +264,24 @@ RSpec.describe DiscourseAi::AiBot::Playground do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#conversation_context" do
|
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
|
it "includes previous posts ordered by post_number" do
|
||||||
context = playground.conversation_context(third_post)
|
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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
RSpec.describe AiPersona do
|
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
|
it "does not leak caches between sites" do
|
||||||
AiPersona.create!(
|
AiPersona.create!(
|
||||||
name: "pun_bot",
|
name: "pun_bot",
|
||||||
|
|
|
@ -8,7 +8,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
|
|
||||||
describe "GET #index" do
|
describe "GET #index" do
|
||||||
it "returns a success response" 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).to be_successful
|
||||||
|
|
||||||
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
|
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
|
||||||
|
@ -17,6 +17,17 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
)
|
)
|
||||||
end
|
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
|
it "returns commands options with each command" do
|
||||||
persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"])
|
persona1 = Fabricate(:ai_persona, name: "search1", commands: ["SearchCommand"])
|
||||||
persona2 =
|
persona2 =
|
||||||
|
@ -24,14 +35,22 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
:ai_persona,
|
:ai_persona,
|
||||||
name: "search2",
|
name: "search2",
|
||||||
commands: [["SearchCommand", { base_query: "test" }]],
|
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
|
expect(response).to be_successful
|
||||||
|
|
||||||
serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id }
|
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 }
|
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"]
|
commands = response.parsed_body["meta"]["commands"]
|
||||||
search_command = commands.find { |c| c["id"] == "Search" }
|
search_command = commands.find { |c| c["id"] == "Search" }
|
||||||
|
|
||||||
|
@ -86,7 +105,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns localized persona names and descriptions" do
|
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 =
|
id =
|
||||||
DiscourseAi::AiBot::Personas::Persona.system_personas[
|
DiscourseAi::AiBot::Personas::Persona.system_personas[
|
||||||
|
@ -102,7 +121,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
|
|
||||||
describe "GET #show" do
|
describe "GET #show" do
|
||||||
it "returns a success response" 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).to be_successful
|
||||||
expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name)
|
expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name)
|
||||||
end
|
end
|
||||||
|
@ -118,12 +137,14 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
commands: [["search", { "base_query" => "test" }]],
|
commands: [["search", { "base_query" => "test" }]],
|
||||||
top_p: 0.1,
|
top_p: 0.1,
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
|
mentionable: true,
|
||||||
|
default_llm: "anthropic:claude-2",
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates a new AiPersona" do
|
it "creates a new AiPersona" do
|
||||||
expect {
|
expect {
|
||||||
post "/admin/plugins/discourse-ai/ai_personas.json",
|
post "/admin/plugins/discourse-ai/ai-personas.json",
|
||||||
params: { ai_persona: valid_attributes }.to_json,
|
params: { ai_persona: valid_attributes }.to_json,
|
||||||
headers: {
|
headers: {
|
||||||
"CONTENT_TYPE" => "application/json",
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
@ -134,6 +155,8 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
expect(persona_json["name"]).to eq("superbot")
|
expect(persona_json["name"]).to eq("superbot")
|
||||||
expect(persona_json["top_p"]).to eq(0.1)
|
expect(persona_json["top_p"]).to eq(0.1)
|
||||||
expect(persona_json["temperature"]).to eq(0.5)
|
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"])
|
persona = AiPersona.find(persona_json["id"])
|
||||||
|
|
||||||
|
@ -146,18 +169,27 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
|
|
||||||
context "with invalid params" do
|
context "with invalid params" do
|
||||||
it "renders a JSON response with errors for the new ai_persona" 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).to have_http_status(:unprocessable_entity)
|
||||||
expect(response.content_type).to include("application/json")
|
expect(response.content_type).to include("application/json")
|
||||||
end
|
end
|
||||||
end
|
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
|
describe "PUT #update" do
|
||||||
it "allows us to trivially clear top_p and temperature" 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)
|
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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
top_p: "",
|
top_p: "",
|
||||||
|
@ -173,7 +205,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not allow temperature and top p changes on stock personas" do
|
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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
top_p: 0.5,
|
top_p: 0.5,
|
||||||
|
@ -186,7 +218,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
|
|
||||||
context "with valid params" do
|
context "with valid params" do
|
||||||
it "updates the requested ai_persona" 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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
name: "SuperBot",
|
name: "SuperBot",
|
||||||
|
@ -207,7 +239,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
|
|
||||||
context "with system personas" do
|
context "with system personas" do
|
||||||
it "does not allow editing of system prompts" 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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
system_prompt: "you are not a helpful bot",
|
system_prompt: "you are not a helpful bot",
|
||||||
|
@ -220,7 +252,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not allow editing of commands" do
|
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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
commands: %w[SearchCommand ImageCommand],
|
commands: %w[SearchCommand ImageCommand],
|
||||||
|
@ -233,7 +265,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not allow editing of name and description cause it is localized" do
|
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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
name: "bob",
|
name: "bob",
|
||||||
|
@ -247,7 +279,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does allow some actions" do
|
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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
|
||||||
|
@ -262,7 +294,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
|
|
||||||
context "with invalid params" do
|
context "with invalid params" do
|
||||||
it "renders a JSON response with errors for the ai_persona" 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: {
|
params: {
|
||||||
ai_persona: {
|
ai_persona: {
|
||||||
name: "",
|
name: "",
|
||||||
|
@ -277,7 +309,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||||
describe "DELETE #destroy" do
|
describe "DELETE #destroy" do
|
||||||
it "destroys the requested ai_persona" do
|
it "destroys the requested ai_persona" do
|
||||||
expect {
|
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)
|
expect(response).to have_http_status(:no_content)
|
||||||
}.to change(AiPersona, :count).by(-1)
|
}.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
|
it "is not allowed to delete system personas" do
|
||||||
expect {
|
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).to have_http_status(:unprocessable_entity)
|
||||||
expect(response.parsed_body["errors"].join).not_to be_blank
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
||||||
# let's make sure this is translated
|
# let's make sure this is translated
|
||||||
|
|
|
@ -30,7 +30,7 @@ RSpec.describe "AI personas", type: :system, js: true do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "allows creation of a persona" do
|
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-list-editor__header .btn-primary").click()
|
||||||
find(".ai-persona-editor__name").set("Test Persona")
|
find(".ai-persona-editor__name").set("Test Persona")
|
||||||
find(".ai-persona-editor__description").fill_in(with: "I am a 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()
|
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
|
persona_id = page.current_path.split("/").last.to_i
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ RSpec.describe "AI personas", type: :system, js: true do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "will not allow deletion or editing of system personas" do
|
it "will not allow deletion or editing of system personas" do
|
||||||
visit "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}"
|
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(page).not_to have_selector(".ai-persona-editor__delete")
|
||||||
expect(find(".ai-persona-editor__system_prompt")).to be_disabled
|
expect(find(".ai-persona-editor__system_prompt")).to be_disabled
|
||||||
end
|
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
|
it "will enable persona right away when you click on enable but does not save side effects" do
|
||||||
persona = Fabricate(:ai_persona, enabled: false)
|
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")
|
find(".ai-persona-editor__name").set("Test Persona 1")
|
||||||
PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle
|
PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle
|
||||||
|
|
|
@ -41,6 +41,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () {
|
||||||
description: "Description",
|
description: "Description",
|
||||||
top_p: 0.8,
|
top_p: 0.8,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
|
mentionable: false,
|
||||||
|
default_llm: "Default LLM",
|
||||||
|
user: null,
|
||||||
|
user_id: null,
|
||||||
|
max_context_posts: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const aiPersona = AiPersona.create({ ...properties });
|
const aiPersona = AiPersona.create({ ...properties });
|
||||||
|
@ -67,6 +72,11 @@ module("Discourse AI | Unit | Model | ai-persona", function () {
|
||||||
description: "Description",
|
description: "Description",
|
||||||
top_p: 0.8,
|
top_p: 0.8,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
|
user: null,
|
||||||
|
user_id: null,
|
||||||
|
default_llm: "Default LLM",
|
||||||
|
mentionable: false,
|
||||||
|
max_context_posts: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const aiPersona = AiPersona.create({ ...properties });
|
const aiPersona = AiPersona.create({ ...properties });
|
||||||
|
|
Loading…
Reference in New Issue