FEATURE: UI to update ai personas on admin page (#290)
Introduces a UI to manage customizable personas (admin only feature) Part of the change was some extensive internal refactoring: - AIBot now has a persona set in the constructor, once set it never changes - Command now takes in bot as a constructor param, so it has the correct persona and is not generating AIBot objects on the fly - Added a .prettierignore file, due to the way ALE is configured in nvim it is a pre-req for prettier to work - Adds a bunch of validations on the AIPersona model, system personas (artist/creative etc...) are all seeded. We now ensure - name uniqueness, and only allow certain properties to be touched for system personas. - (JS note) the client side design takes advantage of nested routes, the parent route for personas gets all the personas via this.store.findAll("ai-persona") then child routes simply reach into this model to find a particular persona. - (JS note) data is sideloaded into the ai-persona model the meta property supplied from the controller, resultSetMeta - This removes ai_bot_enabled_personas and ai_bot_enabled_chat_commands, both should be controlled from the UI on a per persona basis - Fixes a long standing bug in token accounting ... we were doing to_json.length instead of to_json.to_s.length - Amended it so {commands} are always inserted at the end unconditionally, no need to add it to the template of the system message as it just confuses things - Adds a concept of required_commands to stock personas, these are commands that must be configured for this stock persona to show up. - Refactored tests so we stop requiring inference_stubs, it was very confusing to need it, added to plugin.rb for now which at least is clearer - Migrates the persona selector to gjs --------- Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com> Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
parent
491111e5c8
commit
5b5edb22c6
|
@ -0,0 +1,21 @@
|
|||
app/assets/stylesheets/vendor/
|
||||
documentation/
|
||||
package.json
|
||||
config/locales/**/*.yml
|
||||
!config/locales/**/*.en*.yml
|
||||
script/import_scripts/**/*.yml
|
||||
|
||||
plugins/**/lib/javascripts/locale
|
||||
public/
|
||||
!/app/assets/javascripts/discourse/public
|
||||
vendor/
|
||||
app/assets/javascripts/discourse/tests/fixtures
|
||||
spec/
|
||||
node_modules/
|
||||
dist/
|
||||
tmp/
|
||||
|
||||
**/*.rb
|
||||
**/*.html
|
||||
**/*.json
|
||||
**/*.md
|
|
@ -0,0 +1,71 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
before_action :find_ai_persona, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
AiPersona.ordered.map do |persona|
|
||||
# we use a special serializer here cause names and descriptions are
|
||||
# localized for system personas
|
||||
LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
end
|
||||
commands =
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
|
||||
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
|
||||
end
|
||||
render json: { ai_personas: ai_personas, meta: { commands: commands } }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
|
||||
end
|
||||
|
||||
def create
|
||||
ai_persona = AiPersona.new(ai_persona_params)
|
||||
if ai_persona.save
|
||||
render json: { ai_persona: ai_persona }, status: :created
|
||||
else
|
||||
render_json_error ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @ai_persona.update(ai_persona_params)
|
||||
render json: @ai_persona
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @ai_persona.destroy
|
||||
head :no_content
|
||||
else
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_ai_persona
|
||||
@ai_persona = AiPersona.find(params[:id])
|
||||
end
|
||||
|
||||
def ai_persona_params
|
||||
params.require(:ai_persona).permit(
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system_prompt,
|
||||
:enabled,
|
||||
:priority,
|
||||
allowed_group_ids: [],
|
||||
commands: [],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,6 +4,13 @@ class AiPersona < ActiveRecord::Base
|
|||
# places a hard limit, so per site we cache a maximum of 500 classes
|
||||
MAX_PERSONAS_PER_SITE = 500
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
|
||||
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
|
||||
|
||||
before_destroy :ensure_not_system
|
||||
|
||||
class MultisiteHash
|
||||
def initialize(id)
|
||||
@hash = Hash.new { |h, k| h[k] = {} }
|
||||
|
@ -38,61 +45,15 @@ class AiPersona < ActiveRecord::Base
|
|||
@persona_cache ||= MultisiteHash.new("persona_cache")
|
||||
end
|
||||
|
||||
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
|
||||
|
||||
def self.all_personas
|
||||
persona_cache[:value] ||= AiPersona
|
||||
.order(:name)
|
||||
.ordered
|
||||
.where(enabled: true)
|
||||
.all
|
||||
.limit(MAX_PERSONAS_PER_SITE)
|
||||
.map do |ai_persona|
|
||||
name = ai_persona.name
|
||||
description = ai_persona.description
|
||||
ai_persona_id = ai_persona.id
|
||||
allowed_group_ids = ai_persona.allowed_group_ids
|
||||
commands =
|
||||
ai_persona.commands.filter_map do |inner_name|
|
||||
begin
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||
define_singleton_method :name do
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
|
||||
define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_singleton_method :inspect do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_method :initialize do |*args, **kwargs|
|
||||
@ai_persona = AiPersona.find_by(id: ai_persona_id)
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
end
|
||||
end
|
||||
.map(&:class_instance)
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
|
@ -100,6 +61,99 @@ class AiPersona < ActiveRecord::Base
|
|||
def bump_cache
|
||||
self.class.persona_cache.flush!
|
||||
end
|
||||
|
||||
def class_instance
|
||||
allowed_group_ids = self.allowed_group_ids
|
||||
id = self.id
|
||||
system = self.system
|
||||
|
||||
persona_class = DiscourseAi::AiBot::Personas.system_personas_by_id[self.id]
|
||||
if persona_class
|
||||
persona_class.define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :id do
|
||||
id
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :system do
|
||||
system
|
||||
end
|
||||
|
||||
return persona_class
|
||||
end
|
||||
|
||||
name = self.name
|
||||
description = self.description
|
||||
ai_persona_id = self.id
|
||||
commands =
|
||||
self.commands.filter_map do |inner_name|
|
||||
begin
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||
define_singleton_method :id do
|
||||
id
|
||||
end
|
||||
|
||||
define_singleton_method :name do
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
|
||||
define_singleton_method :system do
|
||||
system
|
||||
end
|
||||
|
||||
define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_singleton_method :inspect do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_method :initialize do |*args, **kwargs|
|
||||
@ai_persona = AiPersona.find_by(id: ai_persona_id)
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_persona_unchangeable
|
||||
if system_prompt_changed? || commands_changed? || name_changed? || description_changed?
|
||||
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_not_system
|
||||
if system
|
||||
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
|
@ -110,10 +164,14 @@ end
|
|||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :string default([]), not null, is an Array
|
||||
# system_prompt :string 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 :integer default(0), not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||
root "ai_persona"
|
||||
|
||||
attributes :id,
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system,
|
||||
:priority,
|
||||
:commands,
|
||||
:system_prompt,
|
||||
:allowed_group_ids
|
||||
|
||||
def name
|
||||
object.class_instance.name
|
||||
end
|
||||
|
||||
def description
|
||||
object.class_instance.description
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
export default {
|
||||
resource: "admin.adminPlugins",
|
||||
|
||||
path: "/plugins",
|
||||
|
||||
map() {
|
||||
this.route("discourse-ai", { path: "discourse-ai" }, function () {
|
||||
this.route("ai-personas", { path: "ai_personas" }, function () {
|
||||
this.route("new", { path: "/new" });
|
||||
this.route("show", { path: "/:id" });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default class Adapter extends RestAdapter {
|
||||
jsonMode = true;
|
||||
|
||||
basePath() {
|
||||
return "/admin/plugins/discourse-ai/";
|
||||
}
|
||||
|
||||
pathFor() {
|
||||
return super.pathFor(...arguments) + ".json";
|
||||
}
|
||||
|
||||
apiNameFor() {
|
||||
return "ai-persona";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import RestModel from "discourse/models/rest";
|
||||
|
||||
const ATTRIBUTES = [
|
||||
"name",
|
||||
"description",
|
||||
"commands",
|
||||
"system_prompt",
|
||||
"allowed_group_ids",
|
||||
"enabled",
|
||||
"system",
|
||||
"priority",
|
||||
];
|
||||
|
||||
export default class AiPersona extends RestModel {
|
||||
updateProperties() {
|
||||
let attrs = this.getProperties(ATTRIBUTES);
|
||||
attrs.id = this.id;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
createProperties() {
|
||||
return this.getProperties(ATTRIBUTES);
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
return AiPersona.create(this.createProperties());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { computed, observer } from "@ember/object";
|
||||
import MultiSelectComponent from "select-kit/components/multi-select";
|
||||
|
||||
export default MultiSelectComponent.extend({
|
||||
_modelDisabledChanged: observer("attrs.disabled", function () {
|
||||
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
|
||||
}),
|
||||
|
||||
content: computed(function () {
|
||||
return this.attrs.commands.value;
|
||||
}),
|
||||
|
||||
value: "",
|
||||
|
||||
selectKitOptions: {
|
||||
filterable: true,
|
||||
},
|
||||
});
|
|
@ -0,0 +1,224 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
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 { 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 { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Group from "discourse/models/group";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
import GroupChooser from "select-kit/components/group-chooser";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import AiCommandSelector from "./ai-command-selector";
|
||||
|
||||
export default class PersonaEditor extends Component {
|
||||
@service router;
|
||||
@service store;
|
||||
@service dialog;
|
||||
@service toasts;
|
||||
|
||||
@tracked allGroups = [];
|
||||
@tracked isSaving = false;
|
||||
@tracked editingModel = null;
|
||||
@tracked showDelete = false;
|
||||
|
||||
@action
|
||||
updateModel() {
|
||||
this.editingModel = this.args.model.workingCopy();
|
||||
this.showDelete = !this.args.model.isNew && !this.args.model.system;
|
||||
}
|
||||
|
||||
@action
|
||||
async updateAllGroups() {
|
||||
this.allGroups = await Group.findAll();
|
||||
}
|
||||
|
||||
@action
|
||||
async save() {
|
||||
const isNew = this.args.model.isNew;
|
||||
this.isSaving = true;
|
||||
|
||||
const backupModel = this.args.model.workingCopy();
|
||||
|
||||
this.args.model.setProperties(this.editingModel);
|
||||
try {
|
||||
await this.args.model.save();
|
||||
this.#sortPersonas();
|
||||
if (isNew) {
|
||||
this.args.personas.addObject(this.args.model);
|
||||
this.router.transitionTo(
|
||||
"adminPlugins.discourse-ai.ai-personas.show",
|
||||
this.args.model
|
||||
);
|
||||
} else {
|
||||
this.toasts.success({
|
||||
data: { message: I18n.t("discourse_ai.ai_persona.saved") },
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.args.model.setProperties(backupModel);
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
later(() => {
|
||||
this.isSaving = false;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
delete() {
|
||||
return this.dialog.confirm({
|
||||
message: I18n.t("discourse_ai.ai_persona.confirm_delete"),
|
||||
didConfirm: () => {
|
||||
return this.args.model.destroyRecord().then(() => {
|
||||
this.args.personas.removeObject(this.args.model);
|
||||
this.router.transitionTo(
|
||||
"adminPlugins.discourse-ai.ai-personas.index"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
updateAllowedGroups(ids) {
|
||||
this.editingModel.set("allowed_group_ids", ids);
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleEnabled() {
|
||||
this.args.model.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
|
||||
async togglePriority() {
|
||||
this.args.model.set("priority", !this.args.model.priority);
|
||||
if (!this.args.model.isNew) {
|
||||
try {
|
||||
await this.args.model.update({ priority: this.args.model.priority });
|
||||
|
||||
this.#sortPersonas();
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sortPersonas() {
|
||||
const sorted = this.args.personas.toArray().sort((a, b) => {
|
||||
if (a.priority && !b.priority) {
|
||||
return -1;
|
||||
} else if (!a.priority && b.priority) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
this.args.personas.clear();
|
||||
this.args.personas.setObjects(sorted);
|
||||
}
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="form-horizontal ai-persona-editor"
|
||||
{{didUpdate this.updateModel @model.id}}
|
||||
{{didInsert this.updateModel @model.id}}
|
||||
{{didInsert this.updateAllGroups @model.id}}
|
||||
>
|
||||
<div class="control-group">
|
||||
<DToggleSwitch
|
||||
class="ai-persona-editor__enabled"
|
||||
@state={{@model.enabled}}
|
||||
@label="discourse_ai.ai_persona.enabled"
|
||||
{{on "click" this.toggleEnabled}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group ai-persona-editor__priority">
|
||||
<DToggleSwitch
|
||||
class="ai-persona-editor__priority"
|
||||
@state={{@model.priority}}
|
||||
@label="discourse_ai.ai_persona.priority"
|
||||
{{on "click" this.togglePriority}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="question-circle"
|
||||
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
||||
<Input
|
||||
class="ai-persona-editor__name"
|
||||
@type="text"
|
||||
@value={{this.editingModel.name}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.description"}}</label>
|
||||
<Textarea
|
||||
class="ai-persona-editor__description"
|
||||
@value={{this.editingModel.description}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
|
||||
<AiCommandSelector
|
||||
class="ai-persona-editor__commands"
|
||||
@value={{this.editingModel.commands}}
|
||||
@disabled={{this.editingModel.system}}
|
||||
@commands={{@personas.resultSetMeta.commands}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
|
||||
<GroupChooser
|
||||
@value={{this.editingModel.allowed_group_ids}}
|
||||
@content={{this.allGroups}}
|
||||
@onChange={{this.updateAllowedGroups}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="ai-persona-editor__system_prompt">{{I18n.t
|
||||
"discourse_ai.ai_persona.system_prompt"
|
||||
}}</label>
|
||||
<Textarea
|
||||
class="ai-persona-editor__system_prompt"
|
||||
@value={{this.editingModel.system_prompt}}
|
||||
disabled={{this.editingModel.system}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group ai-persona-editor__action_panel">
|
||||
<DButton
|
||||
class="btn-primary ai-persona-editor__save"
|
||||
@action={{this.save}}
|
||||
@disabled={{this.isSaving}}
|
||||
>{{I18n.t "discourse_ai.ai_persona.save"}}</DButton>
|
||||
{{#if this.showDelete}}
|
||||
<DButton
|
||||
@action={{this.delete}}
|
||||
class="btn-danger ai-persona-editor__delete"
|
||||
>
|
||||
{{I18n.t "discourse_ai.ai_persona.delete"}}
|
||||
</DButton>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { LinkTo } from "@ember/routing";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { cook } from "discourse/lib/text";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import I18n from "discourse-i18n";
|
||||
import AiPersonaEditor from "./ai-persona-editor";
|
||||
|
||||
export default class AiPersonaListEditor extends Component {
|
||||
@tracked _noPersonaText = null;
|
||||
|
||||
get noPersonaText() {
|
||||
if (this._noPersonaText === null) {
|
||||
const raw = I18n.t("discourse_ai.ai_persona.no_persona_selected");
|
||||
cook(raw).then((result) => {
|
||||
this._noPersonaText = result;
|
||||
});
|
||||
}
|
||||
|
||||
return this._noPersonaText;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-persona-list-editor__header">
|
||||
<h3>{{I18n.t "discourse_ai.ai_persona.title"}}</h3>
|
||||
{{#unless @currentPersona.isNew}}
|
||||
<LinkTo
|
||||
@route="adminPlugins.discourse-ai.ai-personas.new"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{icon "plus"}}
|
||||
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
|
||||
</LinkTo>
|
||||
{{/unless}}
|
||||
</div>
|
||||
<div class="content-list ai-persona-list-editor">
|
||||
<ul>
|
||||
{{#each @personas as |persona|}}
|
||||
<li
|
||||
class={{concatClass
|
||||
(if persona.enabled "" "diabled")
|
||||
(if persona.priority "priority")
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="adminPlugins.discourse-ai.ai-personas.show"
|
||||
current-when="true"
|
||||
@model={{persona}}
|
||||
>{{persona.name}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<section class="ai-persona-list-editor__current content-body">
|
||||
{{#if @currentPersona}}
|
||||
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
|
||||
{{else}}
|
||||
<div class="ai-persona-list-editor__empty">
|
||||
{{this.noPersonaText}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
</template>
|
||||
}
|
|
@ -34,7 +34,7 @@ export default class AISuggestionDropdown extends Component {
|
|||
const minCharacterCount = 40;
|
||||
const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
|
||||
const composerFields = document.querySelector(".composer-fields");
|
||||
|
||||
|
||||
if (composerFields) {
|
||||
if (isShowAIButton) {
|
||||
composerFields.classList.add("showing-ai-suggestions");
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
||||
|
||||
function isBotMessage(composer, currentUser) {
|
||||
if (
|
||||
|
@ -25,6 +27,13 @@ export default class BotSelector extends Component {
|
|||
}
|
||||
|
||||
@service currentUser;
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.botOptions && this.composer) {
|
||||
this._value = this.botOptions[0].id;
|
||||
this.composer.metaData = { ai_persona_id: this._value };
|
||||
}
|
||||
}
|
||||
|
||||
get composer() {
|
||||
return this.args?.outletArgs?.model;
|
||||
|
@ -34,7 +43,7 @@ export default class BotSelector extends Component {
|
|||
if (this.currentUser.ai_enabled_personas) {
|
||||
return this.currentUser.ai_enabled_personas.map((persona) => {
|
||||
return {
|
||||
id: persona.name,
|
||||
id: persona.id,
|
||||
name: persona.name,
|
||||
description: persona.description,
|
||||
};
|
||||
|
@ -43,11 +52,21 @@ export default class BotSelector extends Component {
|
|||
}
|
||||
|
||||
get value() {
|
||||
return this._value || this.botOptions[0].id;
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(val) {
|
||||
this._value = val;
|
||||
this.composer.metaData = { ai_persona: val };
|
||||
this.composer.metaData = { ai_persona_id: val };
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="gpt-persona">
|
||||
<DropdownSelectBox
|
||||
@value={{this.value}}
|
||||
@content={{this.botOptions}}
|
||||
@options={{hash icon="robot"}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
<div class="gpt-persona">
|
||||
<DropdownSelectBox
|
||||
@value={{this.value}}
|
||||
@content={{this.botOptions}}
|
||||
@options={{hash icon="robot"}}
|
||||
/>
|
||||
</div>
|
|
@ -0,0 +1,7 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
model() {
|
||||
return this.modelFor("adminPlugins.discourse-ai.ai-personas");
|
||||
},
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model() {
|
||||
const record = this.store.createRecord("ai-persona");
|
||||
// TL0
|
||||
record.set("allowed_group_ids", [10]);
|
||||
return record;
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
this._super(controller, model);
|
||||
controller.set(
|
||||
"allPersonas",
|
||||
this.modelFor("adminPlugins.discourse-ai.ai-personas")
|
||||
);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model(params) {
|
||||
const allPersonas = this.modelFor("adminPlugins.discourse-ai.ai-personas");
|
||||
const id = parseInt(params.id, 10);
|
||||
return allPersonas.findBy("id", id);
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
this._super(controller, model);
|
||||
controller.set(
|
||||
"allPersonas",
|
||||
this.modelFor("adminPlugins.discourse-ai.ai-personas")
|
||||
);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
async model() {
|
||||
return this.store.findAll("ai-persona");
|
||||
},
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default DiscourseRoute.extend({
|
||||
beforeModel() {
|
||||
this.transitionTo("adminPlugins.discourse-ai.ai-personas");
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
<AiPersonaListEditor @personas={{this.model}} />
|
|
@ -0,0 +1,4 @@
|
|||
<AiPersonaListEditor
|
||||
@personas={{this.allPersonas}}
|
||||
@currentPersona={{this.model}}
|
||||
/>
|
|
@ -0,0 +1,4 @@
|
|||
<AiPersonaListEditor
|
||||
@personas={{this.allPersonas}}
|
||||
@currentPersona={{this.model}}
|
||||
/>
|
|
@ -0,0 +1,45 @@
|
|||
.ai-persona-list-editor {
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
&__current {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-personas__container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ai-persona-editor {
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
&__description {
|
||||
width: 500px;
|
||||
}
|
||||
&__system_prompt {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
}
|
||||
&__priority {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.fk-d-tooltip__icon {
|
||||
padding-left: 0.25em;
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,9 +39,38 @@ en:
|
|||
description: "Either gpt-4 or gpt-3-5-turbo or claude-2"
|
||||
|
||||
discourse_ai:
|
||||
title: "AI"
|
||||
modals:
|
||||
select_option: "Select an option..."
|
||||
|
||||
ai_persona:
|
||||
name: Name
|
||||
description: Description
|
||||
system_prompt: System Prompt
|
||||
save: Save
|
||||
saved: AI Persona Saved
|
||||
enabled: Enabled
|
||||
commands: Enabled Commands
|
||||
allowed_groups: Allowed Groups
|
||||
confirm_delete: Are you sure you want to delete this persona?
|
||||
new: New
|
||||
title: "AI Personas"
|
||||
delete: Delete
|
||||
priority: Priority
|
||||
priority_help: Priority personas are displayed to users at the top of the persona list. If multiple personas have priority, they will be sorted alphabetically.
|
||||
no_persona_selected: |
|
||||
## What are AI Personas?
|
||||
|
||||
AI Personas are a powerful feature that allows you to customize the behavior of the AI engine in your Discourse forum. They act as a 'system message' that guides the AI's responses and interactions, helping to create a more personalized and engaging user experience.
|
||||
|
||||
## Why use AI Personas?
|
||||
|
||||
With AI Personas, you can tailor the AI's behavior to better fit the context and tone of your forum. Whether you want the AI to be more formal for a professional setting, more casual for a community forum, or even embody a specific character for a role-playing game, AI Personas give you the flexibility to do so.
|
||||
|
||||
## Group-Specific Access to AI Personas
|
||||
|
||||
Moreover, you can set it up so that certain user groups have access to specific personas. This means you can have different AI behaviors for different sections of your forum, further enhancing the diversity and richness of your community's interactions.
|
||||
|
||||
related_topics:
|
||||
title: "Related Topics"
|
||||
pill: "Related"
|
||||
|
|
|
@ -73,9 +73,7 @@ en:
|
|||
ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning"
|
||||
ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups."
|
||||
ai_bot_enabled_chat_bots: "Available models to act as an AI Bot"
|
||||
ai_bot_enabled_chat_commands: "Available GPT integrations used to provide external functionality to the Forum Helper bot, keep in mind that certain commands may only be available if appropriate API keys are added."
|
||||
ai_bot_add_to_header: "Display a button in the header to start a PM with a AI Bot"
|
||||
ai_bot_enabled_personas: "List of personas available for the AI Bot"
|
||||
|
||||
ai_stability_api_key: "API key for the stability.ai API"
|
||||
ai_stability_engine: "Image generation engine to use for the stability.ai API"
|
||||
|
@ -125,6 +123,8 @@ en:
|
|||
|
||||
ai_bot:
|
||||
personas:
|
||||
cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead"
|
||||
cannot_edit_system_persona: "System personas can only be renamed, you may not edit commands or system prompt, instead disable and make a copy"
|
||||
general:
|
||||
name: Forum Helper
|
||||
description: "General purpose AI Bot capable of performing various tasks"
|
||||
|
@ -184,7 +184,7 @@ en:
|
|||
configuration_hint:
|
||||
one: "Configure the `%{setting}` setting first."
|
||||
other: "Configure these settings first: %{settings}"
|
||||
|
||||
|
||||
sentiment:
|
||||
reports:
|
||||
overall_sentiment:
|
||||
|
|
|
@ -26,4 +26,11 @@ Discourse::Application.routes.draw do
|
|||
|
||||
get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment",
|
||||
:constraints => StaffConstraint.new
|
||||
|
||||
scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do
|
||||
get "/", to: redirect("/admin/plugins/discourse-ai/ai_personas")
|
||||
resources :ai_personas,
|
||||
only: %i[index create show update destroy],
|
||||
controller: "discourse_ai/admin/ai_personas"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -250,29 +250,6 @@ discourse_ai:
|
|||
- gpt-3.5-turbo
|
||||
- gpt-4
|
||||
- claude-2
|
||||
ai_bot_enabled_chat_commands:
|
||||
type: list
|
||||
default: "categories|google|image|search|tags|time|read"
|
||||
client: true
|
||||
choices:
|
||||
- categories
|
||||
- google
|
||||
- image
|
||||
- search
|
||||
- summarize
|
||||
- read
|
||||
- tags
|
||||
- time
|
||||
ai_bot_enabled_personas:
|
||||
type: list
|
||||
default: "general|artist|sql_helper|settings_explorer|researcher|creative"
|
||||
choices:
|
||||
- general
|
||||
- artist
|
||||
- sql_helper
|
||||
- settings_explorer
|
||||
- researcher
|
||||
- creative
|
||||
ai_bot_add_to_header:
|
||||
default: true
|
||||
client: true
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAi::AiBot::Personas.system_personas.each do |persona_class, id|
|
||||
persona = AiPersona.find_by(id: id)
|
||||
if !persona
|
||||
persona = AiPersona.new
|
||||
persona.id = id
|
||||
persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]]
|
||||
persona.enabled = true
|
||||
persona.priority = true if persona_class == DiscourseAi::AiBot::Personas::General
|
||||
end
|
||||
|
||||
names = [
|
||||
persona_class.name,
|
||||
persona_class.name + " 1",
|
||||
persona_class.name + " 2",
|
||||
persona_class.name + SecureRandom.hex,
|
||||
]
|
||||
persona.name = DB.query_single(<<~SQL, names, id).first
|
||||
SELECT guess_name
|
||||
FROM (
|
||||
SELECT unnest(Array[?]) AS guess_name
|
||||
FROM (SELECT 1) as t
|
||||
) x
|
||||
LEFT JOIN ai_personas ON ai_personas.name = x.guess_name AND ai_personas.id <> ?
|
||||
WHERE ai_personas.id IS NULL
|
||||
ORDER BY x.guess_name ASC
|
||||
LIMIT 1
|
||||
SQL
|
||||
|
||||
persona.description = persona_class.description
|
||||
|
||||
persona.system = true
|
||||
instance = persona_class.new
|
||||
persona.commands = instance.commands.map { |command| command.to_s.split("::").last }
|
||||
persona.system_prompt = instance.system_prompt
|
||||
persona.save!(validate: false)
|
||||
end
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSystemAndPriorityToAiPersonas < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :ai_personas, :system, :boolean, null: false, default: false
|
||||
add_column :ai_personas, :priority, :boolean, null: false, default: false
|
||||
end
|
||||
end
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
class RemoveSiteSettings < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
DB.exec(<<~SQL, %w[ai_bot_enabled_chat_commands ai_bot_enabled_personas])
|
||||
DELETE FROM site_settings WHERE name IN (?)
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
|
@ -7,11 +7,12 @@ module DiscourseAi
|
|||
bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID
|
||||
end
|
||||
|
||||
def bot_prompt_with_topic_context(post)
|
||||
super(post).join("\n\n") + "\n\nAssistant:"
|
||||
def bot_prompt_with_topic_context(post, allow_commands:)
|
||||
super(post, allow_commands: allow_commands).join("\n\n") + "\n\nAssistant:"
|
||||
end
|
||||
|
||||
def prompt_limit
|
||||
def prompt_limit(allow_commands: true)
|
||||
# no side channel for commands, so we can ignore allow commands
|
||||
50_000 # https://console.anthropic.com/docs/prompt-design#what-is-a-prompt
|
||||
end
|
||||
|
||||
|
|
|
@ -67,12 +67,12 @@ module DiscourseAi
|
|||
end
|
||||
end
|
||||
|
||||
attr_reader :bot_user
|
||||
attr_reader :bot_user, :persona
|
||||
|
||||
BOT_NOT_FOUND = Class.new(StandardError)
|
||||
MAX_COMPLETIONS = 5
|
||||
|
||||
def self.as(bot_user)
|
||||
def self.as(bot_user, persona_id: nil, persona_name: nil, user: nil)
|
||||
available_bots = [DiscourseAi::AiBot::OpenAiBot, DiscourseAi::AiBot::AnthropicBot]
|
||||
|
||||
bot =
|
||||
|
@ -80,12 +80,23 @@ module DiscourseAi
|
|||
bot_klass.can_reply_as?(bot_user)
|
||||
end
|
||||
|
||||
bot.new(bot_user)
|
||||
persona = nil
|
||||
if persona_id
|
||||
persona = DiscourseAi::AiBot::Personas.find_by(user: user, id: persona_id)
|
||||
raise BOT_NOT_FOUND if persona.nil?
|
||||
end
|
||||
|
||||
if !persona && persona_name
|
||||
persona = DiscourseAi::AiBot::Personas.find_by(user: user, name: persona_name)
|
||||
raise BOT_NOT_FOUND if persona.nil?
|
||||
end
|
||||
|
||||
bot.new(bot_user, persona: persona&.new)
|
||||
end
|
||||
|
||||
def initialize(bot_user)
|
||||
def initialize(bot_user, persona: nil)
|
||||
@bot_user = bot_user
|
||||
@persona = DiscourseAi::AiBot::Personas::General.new
|
||||
@persona = persona || DiscourseAi::AiBot::Personas::General.new
|
||||
end
|
||||
|
||||
def update_pm_title(post)
|
||||
|
@ -113,21 +124,12 @@ module DiscourseAi
|
|||
# do not allow commands when we are at the end of chain (total completions == MAX_COMPLETIONS)
|
||||
allow_commands = (total_completions < MAX_COMPLETIONS)
|
||||
|
||||
@persona = DiscourseAi::AiBot::Personas::General.new(allow_commands: allow_commands)
|
||||
if persona_name = post.topic.custom_fields["ai_persona"]
|
||||
persona_class =
|
||||
DiscourseAi::AiBot::Personas
|
||||
.all(user: post.user)
|
||||
.find { |current| current.name == persona_name }
|
||||
@persona = persona_class.new(allow_commands: allow_commands) if persona_class
|
||||
end
|
||||
|
||||
prompt =
|
||||
if standalone && post.post_custom_prompt
|
||||
username, standalone_prompt = post.post_custom_prompt.custom_prompt.last
|
||||
[build_message(username, standalone_prompt)]
|
||||
else
|
||||
bot_prompt_with_topic_context(post)
|
||||
bot_prompt_with_topic_context(post, allow_commands: allow_commands)
|
||||
end
|
||||
|
||||
redis_stream_key = nil
|
||||
|
@ -225,12 +227,7 @@ module DiscourseAi
|
|||
|
||||
if command_klass = available_commands.detect { |cmd| cmd.invoked?(name) }
|
||||
command =
|
||||
command_klass.new(
|
||||
bot_user: bot_user,
|
||||
args: args,
|
||||
post: bot_reply_post,
|
||||
parent_post: post,
|
||||
)
|
||||
command_klass.new(bot: self, args: args, post: bot_reply_post, parent_post: post)
|
||||
chain_intermediate, bot_reply_post = command.invoke!
|
||||
chain ||= chain_intermediate
|
||||
standalone ||= command.standalone?
|
||||
|
@ -259,38 +256,38 @@ module DiscourseAi
|
|||
0
|
||||
end
|
||||
|
||||
def bot_prompt_with_topic_context(post, prompt: "topic")
|
||||
def bot_prompt_with_topic_context(post, allow_commands:)
|
||||
messages = []
|
||||
conversation = conversation_context(post)
|
||||
|
||||
rendered_system_prompt = system_prompt(post)
|
||||
|
||||
rendered_system_prompt = system_prompt(post, allow_commands: allow_commands)
|
||||
total_prompt_tokens = tokenize(rendered_system_prompt).length + extra_tokens_per_message
|
||||
|
||||
messages =
|
||||
conversation.reduce([]) do |memo, (raw, username, function)|
|
||||
break(memo) if total_prompt_tokens >= prompt_limit
|
||||
prompt_limit = self.prompt_limit(allow_commands: allow_commands)
|
||||
|
||||
tokens = tokenize(raw.to_s)
|
||||
conversation.each do |raw, username, function|
|
||||
break if total_prompt_tokens >= prompt_limit
|
||||
|
||||
while !raw.blank? &&
|
||||
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
|
||||
raw = raw[0..-100] || ""
|
||||
tokens = tokenize(raw.to_s)
|
||||
end
|
||||
tokens = tokenize(raw.to_s + username.to_s)
|
||||
|
||||
next(memo) if raw.blank?
|
||||
|
||||
total_prompt_tokens += tokens.length + extra_tokens_per_message
|
||||
memo.unshift(build_message(username, raw, function: !!function))
|
||||
while !raw.blank? &&
|
||||
tokens.length + total_prompt_tokens + extra_tokens_per_message > prompt_limit
|
||||
raw = raw[0..-100] || ""
|
||||
tokens = tokenize(raw.to_s + username.to_s)
|
||||
end
|
||||
|
||||
next if raw.blank?
|
||||
|
||||
total_prompt_tokens += tokens.length + extra_tokens_per_message
|
||||
messages.unshift(build_message(username, raw, function: !!function))
|
||||
end
|
||||
|
||||
messages.unshift(build_message(bot_user.username, rendered_system_prompt, system: true))
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
def prompt_limit
|
||||
def prompt_limit(allow_commands: false)
|
||||
raise NotImplemented
|
||||
end
|
||||
|
||||
|
@ -300,7 +297,7 @@ module DiscourseAi
|
|||
You will never respond with anything but a topic title.
|
||||
Suggest a 7 word title for the following topic without quoting any of it:
|
||||
|
||||
#{post.topic.posts.map(&:raw).join("\n\n")[0..prompt_limit]}
|
||||
#{post.topic.posts.map(&:raw).join("\n\n")[0..prompt_limit(allow_commands: false)]}
|
||||
TEXT
|
||||
end
|
||||
|
||||
|
@ -312,12 +309,14 @@ module DiscourseAi
|
|||
@style = style
|
||||
end
|
||||
|
||||
def system_prompt(post)
|
||||
def system_prompt(post, allow_commands:)
|
||||
return "You are a helpful Bot" if @style == :simple
|
||||
|
||||
@persona.render_system_prompt(
|
||||
topic: post.topic,
|
||||
render_function_instructions: include_function_instructions_in_system_prompt?,
|
||||
allow_commands: allow_commands,
|
||||
render_function_instructions:
|
||||
allow_commands && include_function_instructions_in_system_prompt?,
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -40,10 +40,11 @@ module DiscourseAi
|
|||
end
|
||||
end
|
||||
|
||||
attr_reader :bot_user
|
||||
attr_reader :bot_user, :bot
|
||||
|
||||
def initialize(bot_user:, args:, post: nil, parent_post: nil)
|
||||
@bot_user = bot_user
|
||||
def initialize(bot:, args:, post: nil, parent_post: nil)
|
||||
@bot = bot
|
||||
@bot_user = bot&.bot_user
|
||||
@args = args
|
||||
@post = post
|
||||
@parent_post = parent_post
|
||||
|
@ -61,10 +62,6 @@ module DiscourseAi
|
|||
@invoked = false
|
||||
end
|
||||
|
||||
def bot
|
||||
@bot ||= DiscourseAi::AiBot::Bot.as(bot_user)
|
||||
end
|
||||
|
||||
def tokenizer
|
||||
bot.tokenizer
|
||||
end
|
||||
|
|
|
@ -76,7 +76,9 @@ module DiscourseAi
|
|||
) do
|
||||
Personas
|
||||
.all(user: scope.user)
|
||||
.map { |persona| { name: persona.name, description: persona.description } }
|
||||
.map do |persona|
|
||||
{ id: persona.id, name: persona.name, description: persona.description }
|
||||
end
|
||||
end
|
||||
|
||||
plugin.add_to_serializer(
|
||||
|
@ -112,7 +114,11 @@ module DiscourseAi
|
|||
:topic_view,
|
||||
:ai_persona_name,
|
||||
include_condition: -> { SiteSetting.ai_bot_enabled && object.topic.private_message? },
|
||||
) { topic.custom_fields["ai_persona"] }
|
||||
) do
|
||||
id = topic.custom_fields["ai_persona_id"]
|
||||
name = DiscourseAi::AiBot::Personas.find_by(user: scope.user, id: id.to_i)&.name if id
|
||||
name || topic.custom_fields["ai_persona"]
|
||||
end
|
||||
|
||||
plugin.on(:post_created) do |post|
|
||||
bot_ids = BOTS.map(&:first)
|
||||
|
@ -140,7 +146,7 @@ module DiscourseAi
|
|||
end
|
||||
|
||||
if plugin.respond_to?(:register_editable_topic_custom_field)
|
||||
plugin.register_editable_topic_custom_field(:ai_persona)
|
||||
plugin.register_editable_topic_custom_field(:ai_persona_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,10 +6,24 @@ module ::Jobs
|
|||
|
||||
def execute(args)
|
||||
return unless bot_user = User.find_by(id: args[:bot_user_id])
|
||||
return unless bot = DiscourseAi::AiBot::Bot.as(bot_user)
|
||||
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
|
||||
bot.reply_to(post)
|
||||
kwargs = {}
|
||||
kwargs[:user] = post.user
|
||||
if persona_id = post.topic.custom_fields["ai_persona_id"]
|
||||
kwargs[:persona_id] = persona_id.to_i
|
||||
else
|
||||
kwargs[:persona_name] = post.topic.custom_fields["ai_persona"]
|
||||
end
|
||||
|
||||
begin
|
||||
bot = DiscourseAi::AiBot::Bot.as(bot_user, **kwargs)
|
||||
bot.reply_to(post)
|
||||
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
|
||||
Rails.logger.warn(
|
||||
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,13 +12,16 @@ module DiscourseAi
|
|||
open_ai_bot_ids.include?(bot_user.id)
|
||||
end
|
||||
|
||||
def prompt_limit
|
||||
# note this is about 100 tokens over, OpenAI have a more optimal representation
|
||||
@function_size ||= tokenize(available_functions.to_json).length
|
||||
|
||||
def prompt_limit(allow_commands:)
|
||||
# provide a buffer of 120 tokens - our function counting is not
|
||||
# 100% accurate and getting numbers to align exactly is very hard
|
||||
buffer = @function_size + reply_params[:max_tokens] + 120
|
||||
buffer = reply_params[:max_tokens] + 50
|
||||
|
||||
if allow_commands
|
||||
# note this is about 100 tokens over, OpenAI have a more optimal representation
|
||||
@function_size ||= tokenize(available_functions.to_json.to_s).length
|
||||
buffer += @function_size
|
||||
end
|
||||
|
||||
if bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_ID
|
||||
8192 - buffer
|
||||
|
|
|
@ -8,6 +8,10 @@ module DiscourseAi
|
|||
[Commands::ImageCommand]
|
||||
end
|
||||
|
||||
def required_commands
|
||||
[Commands::ImageCommand]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are artistbot and you are here to help people generate images.
|
||||
|
@ -26,9 +30,6 @@ module DiscourseAi
|
|||
- When generating images, usually opt to generate 4 images unless the user specifies otherwise.
|
||||
- Be creative with your prompts, offer diverse options
|
||||
- You can use the seeds to regenerate the same image and amend the prompt keeping general style
|
||||
|
||||
{commands}
|
||||
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,7 +5,15 @@ module DiscourseAi
|
|||
module Personas
|
||||
class General < Persona
|
||||
def commands
|
||||
all_available_commands
|
||||
[
|
||||
Commands::SearchCommand,
|
||||
Commands::GoogleCommand,
|
||||
Commands::ImageCommand,
|
||||
Commands::ReadCommand,
|
||||
Commands::ImageCommand,
|
||||
Commands::CategoriesCommand,
|
||||
Commands::TagsCommand,
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
|
@ -19,8 +27,6 @@ module DiscourseAi
|
|||
The description is: {site_description}
|
||||
The participants in this conversation are: {participants}
|
||||
The date now is: {time}, much has changed since you were trained.
|
||||
|
||||
{commands}
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,28 +3,43 @@
|
|||
module DiscourseAi
|
||||
module AiBot
|
||||
module Personas
|
||||
def self.all(user: nil)
|
||||
personas = [Personas::General, Personas::SqlHelper]
|
||||
personas << Personas::Artist if SiteSetting.ai_stability_api_key.present?
|
||||
personas << Personas::SettingsExplorer
|
||||
personas << Personas::Researcher if SiteSetting.ai_google_custom_search_api_key.present?
|
||||
personas << Personas::Creative
|
||||
def self.system_personas
|
||||
@system_personas ||= {
|
||||
Personas::General => -1,
|
||||
Personas::SqlHelper => -2,
|
||||
Personas::Artist => -3,
|
||||
Personas::SettingsExplorer => -4,
|
||||
Personas::Researcher => -5,
|
||||
Personas::Creative => -6,
|
||||
}
|
||||
end
|
||||
|
||||
personas_allowed = SiteSetting.ai_bot_enabled_personas.split("|")
|
||||
def self.system_personas_by_id
|
||||
@system_personas_by_id ||= system_personas.invert
|
||||
end
|
||||
|
||||
def self.all(user:)
|
||||
personas =
|
||||
personas.filter do |persona|
|
||||
personas_allowed.include?(persona.to_s.demodulize.underscore)
|
||||
AiPersona.all_personas.filter { |persona| user.in_any_groups?(persona.allowed_group_ids) }
|
||||
|
||||
# this needs to be dynamic cause site settings may change
|
||||
all_available_commands = Persona.all_available_commands
|
||||
|
||||
personas.filter do |persona|
|
||||
if persona.system
|
||||
instance = persona.new
|
||||
(
|
||||
instance.required_commands == [] ||
|
||||
(instance.required_commands - all_available_commands).empty?
|
||||
)
|
||||
else
|
||||
true
|
||||
end
|
||||
|
||||
if user
|
||||
personas.concat(
|
||||
AiPersona.all_personas.filter do |persona|
|
||||
user.in_any_groups?(persona.allowed_group_ids)
|
||||
end,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
personas
|
||||
def self.find_by(id: nil, name: nil, user:)
|
||||
all(user: user).find { |persona| persona.id == id || persona.name == name }
|
||||
end
|
||||
|
||||
class Persona
|
||||
|
@ -36,16 +51,16 @@ module DiscourseAi
|
|||
I18n.t("discourse_ai.ai_bot.personas.#{to_s.demodulize.underscore}.description")
|
||||
end
|
||||
|
||||
def initialize(allow_commands: true)
|
||||
@allow_commands = allow_commands
|
||||
end
|
||||
|
||||
def commands
|
||||
[]
|
||||
end
|
||||
|
||||
def required_commands
|
||||
[]
|
||||
end
|
||||
|
||||
def render_commands(render_function_instructions:)
|
||||
return +"" if !@allow_commands
|
||||
return +"" if available_commands.empty?
|
||||
|
||||
result = +""
|
||||
if render_function_instructions
|
||||
|
@ -57,33 +72,39 @@ module DiscourseAi
|
|||
result
|
||||
end
|
||||
|
||||
def render_system_prompt(topic: nil, render_function_instructions: true)
|
||||
def render_system_prompt(
|
||||
topic: nil,
|
||||
render_function_instructions: true,
|
||||
allow_commands: true
|
||||
)
|
||||
substitutions = {
|
||||
site_url: Discourse.base_url,
|
||||
site_title: SiteSetting.title,
|
||||
site_description: SiteSetting.site_description,
|
||||
time: Time.zone.now,
|
||||
commands: render_commands(render_function_instructions: render_function_instructions),
|
||||
}
|
||||
|
||||
substitutions[:participants] = topic.allowed_users.map(&:username).join(", ") if topic
|
||||
|
||||
system_prompt.gsub(/\{(\w+)\}/) do |match|
|
||||
found = substitutions[match[1..-2].to_sym]
|
||||
found.nil? ? match : found.to_s
|
||||
prompt =
|
||||
system_prompt.gsub(/\{(\w+)\}/) do |match|
|
||||
found = substitutions[match[1..-2].to_sym]
|
||||
found.nil? ? match : found.to_s
|
||||
end
|
||||
|
||||
if allow_commands
|
||||
prompt += render_commands(render_function_instructions: render_function_instructions)
|
||||
end
|
||||
|
||||
prompt
|
||||
end
|
||||
|
||||
def available_commands
|
||||
return [] if !@allow_commands
|
||||
|
||||
return @available_commands if @available_commands
|
||||
|
||||
@available_commands = all_available_commands.filter { |cmd| commands.include?(cmd) }
|
||||
end
|
||||
|
||||
def available_functions
|
||||
return [] if !@allow_commands
|
||||
# note if defined? can be a problem in test
|
||||
# this can never be nil so it is safe
|
||||
return @available_functions if @available_functions
|
||||
|
@ -109,15 +130,17 @@ module DiscourseAi
|
|||
@function_list
|
||||
end
|
||||
|
||||
def all_available_commands
|
||||
return @cmds if @cmds
|
||||
|
||||
def self.all_available_commands
|
||||
all_commands = [
|
||||
Commands::CategoriesCommand,
|
||||
Commands::TimeCommand,
|
||||
Commands::SearchCommand,
|
||||
Commands::SummarizeCommand,
|
||||
Commands::ReadCommand,
|
||||
Commands::DbSchemaCommand,
|
||||
Commands::SearchSettingsCommand,
|
||||
Commands::SummarizeCommand,
|
||||
Commands::SettingContextCommand,
|
||||
]
|
||||
|
||||
all_commands << Commands::TagsCommand if SiteSetting.tagging_enabled
|
||||
|
@ -127,8 +150,11 @@ module DiscourseAi
|
|||
all_commands << Commands::GoogleCommand
|
||||
end
|
||||
|
||||
allowed_commands = SiteSetting.ai_bot_enabled_chat_commands.split("|")
|
||||
@cmds = all_commands.filter { |klass| allowed_commands.include?(klass.name) }
|
||||
all_commands
|
||||
end
|
||||
|
||||
def all_available_commands
|
||||
@cmds ||= self.class.all_available_commands
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,6 +8,10 @@ module DiscourseAi
|
|||
[Commands::GoogleCommand]
|
||||
end
|
||||
|
||||
def required_commands
|
||||
[Commands::GoogleCommand]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are research bot. With access to the internet you can find information for users.
|
||||
|
@ -15,9 +19,6 @@ module DiscourseAi
|
|||
- You fully understand Discourse Markdown and generate it.
|
||||
- When generating responses you always cite your sources.
|
||||
- When possible you also quote the sources.
|
||||
|
||||
{commands}
|
||||
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,9 +25,6 @@ module DiscourseAi
|
|||
- Keep in mind that setting names are always a single word separated by underscores. eg. 'site_description'
|
||||
|
||||
Current time is: {time}
|
||||
|
||||
{commands}
|
||||
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,8 +63,6 @@ module DiscourseAi
|
|||
{{
|
||||
#{self.class.schema}
|
||||
}}
|
||||
|
||||
{commands}
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,6 +17,7 @@ enabled_site_setting :discourse_ai_enabled
|
|||
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||
|
||||
register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss"
|
||||
register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss"
|
||||
|
||||
register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss"
|
||||
register_asset "stylesheets/modules/embeddings/common/semantic-search.scss"
|
||||
|
@ -61,6 +62,8 @@ after_initialize do
|
|||
require_relative "lib/modules/ai_bot/entry_point"
|
||||
require_relative "lib/discourse_automation/llm_triage"
|
||||
|
||||
add_admin_route "discourse_ai.title", "discourse-ai"
|
||||
|
||||
[
|
||||
DiscourseAi::Embeddings::EntryPoint.new,
|
||||
DiscourseAi::NSFW::EntryPoint.new,
|
||||
|
@ -80,4 +83,10 @@ after_initialize do
|
|||
on(:reviewable_transitioned_to) do |new_status, reviewable|
|
||||
ModelAccuracy.adjust_model_accuracy(new_status, reviewable)
|
||||
end
|
||||
|
||||
if Rails.env.test?
|
||||
require_relative "spec/support/openai_completions_inference_stubs"
|
||||
require_relative "spec/support/anthropic_completion_stubs"
|
||||
require_relative "spec/support/stable_diffusion_stubs"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
Fabricator(:ai_persona) do
|
||||
name "test_bot"
|
||||
description "I am a test bot"
|
||||
system_prompt "You are a test bot"
|
||||
end
|
|
@ -17,8 +17,7 @@ module ::DiscourseAi
|
|||
|
||||
describe "system message" do
|
||||
it "includes the full command framework" do
|
||||
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
|
||||
prompt = bot.system_prompt(post)
|
||||
prompt = bot.system_prompt(post, allow_commands: true)
|
||||
|
||||
expect(prompt).to include("read")
|
||||
expect(prompt).to include("search_query")
|
||||
|
@ -27,7 +26,6 @@ module ::DiscourseAi
|
|||
|
||||
describe "parsing a reply prompt" do
|
||||
it "can correctly predict that a completion needs to be cancelled" do
|
||||
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
|
||||
functions = DiscourseAi::AiBot::Bot::FunctionCalls.new
|
||||
|
||||
# note anthropic API has a silly leading space, we need to make sure we can handle that
|
||||
|
@ -57,7 +55,6 @@ module ::DiscourseAi
|
|||
end
|
||||
|
||||
it "can correctly detect commands from a prompt" do
|
||||
SiteSetting.ai_bot_enabled_chat_commands = "read|search"
|
||||
functions = DiscourseAi::AiBot::Bot::FunctionCalls.new
|
||||
|
||||
# note anthropic API has a silly leading space, we need to make sure we can handle that
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../support/openai_completions_inference_stubs"
|
||||
|
||||
class FakeBot < DiscourseAi::AiBot::Bot
|
||||
class Tokenizer
|
||||
def tokenize(text)
|
||||
|
@ -13,7 +11,7 @@ class FakeBot < DiscourseAi::AiBot::Bot
|
|||
Tokenizer.new
|
||||
end
|
||||
|
||||
def prompt_limit
|
||||
def prompt_limit(allow_commands: false)
|
||||
10_000
|
||||
end
|
||||
|
||||
|
@ -115,7 +113,7 @@ describe DiscourseAi::AiBot::Bot do
|
|||
SiteSetting.title = "My Forum"
|
||||
SiteSetting.site_description = "My Forum Description"
|
||||
|
||||
system_prompt = bot.system_prompt(second_post)
|
||||
system_prompt = bot.system_prompt(second_post, allow_commands: true)
|
||||
|
||||
expect(system_prompt).to include(SiteSetting.title)
|
||||
expect(system_prompt).to include(SiteSetting.site_description)
|
||||
|
@ -135,7 +133,7 @@ describe DiscourseAi::AiBot::Bot do
|
|||
},
|
||||
}
|
||||
|
||||
prompt = bot.bot_prompt_with_topic_context(second_post)
|
||||
prompt = bot.bot_prompt_with_topic_context(second_post, allow_commands: true)
|
||||
|
||||
req_opts = bot.reply_params.merge({ functions: bot.available_functions, stream: true })
|
||||
|
||||
|
@ -148,7 +146,7 @@ describe DiscourseAi::AiBot::Bot do
|
|||
|
||||
result =
|
||||
DiscourseAi::AiBot::Commands::SearchCommand
|
||||
.new(bot_user: nil, args: nil)
|
||||
.new(bot: nil, args: nil)
|
||||
.process(query: "test search")
|
||||
.to_json
|
||||
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::CategoriesCommand do
|
||||
describe "#generate_categories_info" do
|
||||
it "can generate correct info" do
|
||||
Fabricate(:category, name: "america", posts_year: 999)
|
||||
|
||||
info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(bot_user: nil, args: nil).process
|
||||
info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(bot: nil, args: nil).process
|
||||
expect(info.to_s).to include("america")
|
||||
expect(info.to_s).to include("999")
|
||||
end
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::Command do
|
||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||
let(:command) { DiscourseAi::AiBot::Commands::GoogleCommand.new(bot_user: bot_user, args: nil) }
|
||||
let(:command) { DiscourseAi::AiBot::Commands::GoogleCommand.new(bot: nil, args: nil) }
|
||||
|
||||
before { SiteSetting.ai_bot_enabled = true }
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::DbSchemaCommand do
|
||||
let(:command) { DiscourseAi::AiBot::Commands::DbSchemaCommand.new(bot_user: nil, args: nil) }
|
||||
let(:command) { DiscourseAi::AiBot::Commands::DbSchemaCommand.new(bot: nil, args: nil) }
|
||||
describe "#process" do
|
||||
it "returns rich schema for tables" do
|
||||
result = command.process(tables: "posts,topics")
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
|
||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||
let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) }
|
||||
|
||||
before { SiteSetting.ai_bot_enabled = true }
|
||||
|
||||
|
@ -19,7 +20,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
|
|||
"https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term",
|
||||
).to_return(status: 200, body: json_text, headers: {})
|
||||
|
||||
google = described_class.new(bot_user: bot_user, post: post, args: {}.to_json)
|
||||
google = described_class.new(bot: nil, post: post, args: {}.to_json)
|
||||
info = google.process(query: "some search term").to_json
|
||||
|
||||
expect(google.description_args[:count]).to eq(0)
|
||||
|
@ -61,11 +62,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
|
|||
).to_return(status: 200, body: json_text, headers: {})
|
||||
|
||||
google =
|
||||
described_class.new(
|
||||
bot_user: bot_user,
|
||||
post: post,
|
||||
args: { query: "some search term" }.to_json,
|
||||
)
|
||||
described_class.new(bot: bot, post: post, args: { query: "some search term" }.to_json)
|
||||
|
||||
info = google.process(query: "some search term").to_json
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do
|
||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||
let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) }
|
||||
|
||||
before { SiteSetting.ai_bot_enabled = true }
|
||||
|
||||
|
@ -30,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::ImageCommand do
|
|||
end
|
||||
.to_return(status: 200, body: { artifacts: artifacts }.to_json)
|
||||
|
||||
image = described_class.new(bot_user: bot_user, post: post, args: nil)
|
||||
image = described_class.new(bot: bot, post: post, args: nil)
|
||||
|
||||
info = image.process(prompts: prompts).to_json
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do
|
||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||
let(:bot) { DiscourseAi::AiBot::Bot.as(bot_user) }
|
||||
|
||||
fab!(:parent_category) { Fabricate(:category, name: "animals") }
|
||||
fab!(:category) { Fabricate(:category, parent_category: parent_category, name: "amazing-cat") }
|
||||
|
@ -31,7 +32,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::ReadCommand do
|
|||
Fabricate(:post, topic: topic_with_tags, raw: "hello there")
|
||||
Fabricate(:post, topic: topic_with_tags, raw: "mister sam")
|
||||
|
||||
read = described_class.new(bot_user: bot_user, args: nil)
|
||||
read = described_class.new(bot: bot, args: nil)
|
||||
|
||||
results = read.process(topic_id: topic_id)
|
||||
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
require_relative "../../../../support/embeddings_generation_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||
|
||||
before { SearchIndexer.enable }
|
||||
after { SearchIndexer.disable }
|
||||
|
||||
|
@ -33,7 +28,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
|||
describe "#process" do
|
||||
it "can handle no results" do
|
||||
post1 = Fabricate(:post, topic: topic_with_tags)
|
||||
search = described_class.new(bot_user: bot_user, post: post1, args: nil)
|
||||
search = described_class.new(bot: nil, post: post1, args: nil)
|
||||
|
||||
results = search.process(query: "order:fake ABDDCDCEDGDG")
|
||||
|
||||
|
@ -64,7 +59,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
|||
)
|
||||
|
||||
post1 = Fabricate(:post, topic: topic_with_tags)
|
||||
search = described_class.new(bot_user: bot_user, post: post1, args: nil)
|
||||
search = described_class.new(bot: nil, post: post1, args: nil)
|
||||
|
||||
DiscourseAi::Embeddings::VectorRepresentations::AllMpnetBaseV2
|
||||
.any_instance
|
||||
|
@ -83,7 +78,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
|||
|
||||
post1 = Fabricate(:post, topic: topic_with_tags)
|
||||
|
||||
search = described_class.new(bot_user: bot_user, post: post1, args: nil)
|
||||
search = described_class.new(bot: nil, post: post1, args: nil)
|
||||
|
||||
results = search.process(limit: 1, user: post1.user.username)
|
||||
expect(results[:rows].to_s).to include("/subfolder" + post1.url)
|
||||
|
@ -91,7 +86,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
|||
|
||||
it "returns category and tags" do
|
||||
post1 = Fabricate(:post, topic: topic_with_tags)
|
||||
search = described_class.new(bot_user: bot_user, post: post1, args: nil)
|
||||
search = described_class.new(bot: nil, post: post1, args: nil)
|
||||
results = search.process(user: post1.user.username)
|
||||
|
||||
row = results[:rows].first
|
||||
|
@ -109,7 +104,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
|
|||
_post3 = Fabricate(:post, user: post1.user)
|
||||
|
||||
# search has no built in support for limit: so handle it from the outside
|
||||
search = described_class.new(bot_user: bot_user, post: post1, args: nil)
|
||||
search = described_class.new(bot: nil, post: post1, args: nil)
|
||||
|
||||
results = search.process(limit: 2, user: post1.user.username)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::SearchSettingsCommand do
|
||||
let(:search) { described_class.new(bot_user: nil, args: nil) }
|
||||
let(:search) { described_class.new(bot: nil, args: nil) }
|
||||
|
||||
describe "#process" do
|
||||
it "can handle no results" do
|
||||
|
@ -19,7 +19,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchSettingsCommand do
|
|||
|
||||
it "can return descriptions if there are few matches" do
|
||||
results =
|
||||
search.process(query: "this will not be found!@,default_locale,ai_bot_enabled_personas")
|
||||
search.process(query: "this will not be found!@,default_locale,ai_bot_enabled_chat_bots")
|
||||
|
||||
expect(results[:rows].length).to eq(2)
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
|
||||
let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
|
||||
let(:bot) { DiscourseAi::AiBot::OpenAiBot.new(bot_user) }
|
||||
|
||||
before { SiteSetting.ai_bot_enabled = true }
|
||||
|
||||
|
@ -16,7 +15,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
|
|||
body: JSON.dump({ choices: [{ message: { content: "summary stuff" } }] }),
|
||||
)
|
||||
|
||||
summarizer = described_class.new(bot_user: bot_user, args: nil, post: post)
|
||||
summarizer = described_class.new(bot: bot, args: nil, post: post)
|
||||
info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?")
|
||||
|
||||
expect(info).to include("Topic summarized")
|
||||
|
@ -32,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
|
|||
topic = Fabricate(:topic, category_id: category.id)
|
||||
post = Fabricate(:post, topic: topic)
|
||||
|
||||
summarizer = described_class.new(bot_user: bot_user, post: post, args: nil)
|
||||
summarizer = described_class.new(bot: bot, post: post, args: nil)
|
||||
info = summarizer.process(topic_id: post.topic_id, guidance: "why did it happen?")
|
||||
|
||||
expect(info).not_to include(post.raw)
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do
|
||||
describe "#process" do
|
||||
it "can generate correct info" do
|
||||
|
@ -10,7 +8,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do
|
|||
Fabricate(:tag, name: "america", public_topic_count: 100)
|
||||
Fabricate(:tag, name: "not_here", public_topic_count: 0)
|
||||
|
||||
info = DiscourseAi::AiBot::Commands::TagsCommand.new(bot_user: nil, args: nil).process
|
||||
info = DiscourseAi::AiBot::Commands::TagsCommand.new(bot: nil, args: nil).process
|
||||
|
||||
expect(info.to_s).to include("america")
|
||||
expect(info.to_s).not_to include("not_here")
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
#frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiBot::Commands::TimeCommand do
|
||||
describe "#process" do
|
||||
it "can generate correct info" do
|
||||
freeze_time
|
||||
|
||||
args = { timezone: "America/Los_Angeles" }
|
||||
info = DiscourseAi::AiBot::Commands::TimeCommand.new(bot_user: nil, args: nil).process(**args)
|
||||
info = DiscourseAi::AiBot::Commands::TimeCommand.new(bot: nil, args: nil).process(**args)
|
||||
|
||||
expect(info).to eq({ args: args, time: Time.now.in_time_zone("America/Los_Angeles").to_s })
|
||||
expect(info.to_s).not_to include("not_here")
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../../support/openai_completions_inference_stubs"
|
||||
require_relative "../../../../../support/anthropic_completion_stubs"
|
||||
|
||||
RSpec.describe Jobs::CreateAiReply do
|
||||
before do
|
||||
# got to do this cause we include times in system message
|
||||
|
@ -31,7 +28,10 @@ RSpec.describe Jobs::CreateAiReply do
|
|||
freeze_time
|
||||
|
||||
OpenAiCompletionsInferenceStubs.stub_streamed_response(
|
||||
DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context(post),
|
||||
DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context(
|
||||
post,
|
||||
allow_commands: true,
|
||||
),
|
||||
deltas,
|
||||
model: bot.model_for,
|
||||
req_opts: {
|
||||
|
@ -83,7 +83,10 @@ RSpec.describe Jobs::CreateAiReply do
|
|||
bot_user = User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V2_ID)
|
||||
|
||||
AnthropicCompletionStubs.stub_streamed_response(
|
||||
DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context(post),
|
||||
DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context(
|
||||
post,
|
||||
allow_commands: true,
|
||||
),
|
||||
deltas,
|
||||
model: "claude-2",
|
||||
req_opts: {
|
||||
|
|
|
@ -19,26 +19,6 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
|
|||
SiteSetting.ai_bot_enabled = true
|
||||
end
|
||||
|
||||
context "when changing available commands" do
|
||||
it "contains all commands by default" do
|
||||
# this will break as we add commands, but it is important as a sanity check
|
||||
SiteSetting.ai_stability_api_key = "test"
|
||||
SiteSetting.ai_google_custom_search_api_key = "test"
|
||||
SiteSetting.ai_google_custom_search_cx = "test"
|
||||
|
||||
expect(subject.available_commands.length).to eq(
|
||||
SiteSetting.ai_bot_enabled_chat_commands.split("|").length,
|
||||
)
|
||||
end
|
||||
it "can properly filter out commands" do
|
||||
SiteSetting.ai_bot_enabled_chat_commands = "time|tags"
|
||||
expect(subject.available_commands.length).to eq(2)
|
||||
expect(subject.available_commands).to eq(
|
||||
[DiscourseAi::AiBot::Commands::TimeCommand, DiscourseAi::AiBot::Commands::TagsCommand],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when cleaning usernames" do
|
||||
it "can properly clean usernames so OpenAI allows it" do
|
||||
expect(subject.clean_username("test test")).to eq("test_test")
|
||||
|
@ -51,7 +31,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
|
|||
fab!(:post_1) { Fabricate(:post, topic: topic, raw: post_body(1), post_number: 1) }
|
||||
|
||||
it "includes it in the prompt" do
|
||||
prompt_messages = subject.bot_prompt_with_topic_context(post_1)
|
||||
prompt_messages = subject.bot_prompt_with_topic_context(post_1, allow_commands: true)
|
||||
|
||||
post_1_message = prompt_messages[-1]
|
||||
|
||||
|
@ -65,7 +45,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
|
|||
fab!(:post_1) { Fabricate(:post, topic: topic, raw: "test " * 6000, post_number: 1) }
|
||||
|
||||
it "trims the prompt" do
|
||||
prompt_messages = subject.bot_prompt_with_topic_context(post_1)
|
||||
prompt_messages = subject.bot_prompt_with_topic_context(post_1, allow_commands: true)
|
||||
|
||||
# trimming is tricky... it needs to account for system message as
|
||||
# well... just make sure we trim for now
|
||||
|
@ -81,7 +61,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
|
|||
let!(:post_3) { Fabricate(:post, topic: topic, raw: post_body(3), post_number: 3) }
|
||||
|
||||
it "includes them in the prompt respecting the post number order" do
|
||||
prompt_messages = subject.bot_prompt_with_topic_context(post_3)
|
||||
prompt_messages = subject.bot_prompt_with_topic_context(post_3, allow_commands: true)
|
||||
|
||||
# negative cause we may have grounding prompts
|
||||
expect(prompt_messages[-3][:role]).to eq("user")
|
||||
|
|
|
@ -16,8 +16,6 @@ class TestPersona < DiscourseAi::AiBot::Personas::Persona
|
|||
{site_description}
|
||||
{participants}
|
||||
{time}
|
||||
|
||||
{commands}
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
|
@ -34,18 +32,20 @@ module DiscourseAi::AiBot::Personas
|
|||
topic
|
||||
end
|
||||
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
after do
|
||||
# we are rolling back transactions so we can create poison cache
|
||||
AiPersona.persona_cache.flush!
|
||||
end
|
||||
|
||||
it "can disable commands via constructor" do
|
||||
persona = TestPersona.new(allow_commands: false)
|
||||
fab!(:user)
|
||||
|
||||
rendered =
|
||||
persona.render_system_prompt(topic: topic_with_users, render_function_instructions: true)
|
||||
it "can disable commands" do
|
||||
persona = TestPersona.new
|
||||
|
||||
rendered = persona.render_system_prompt(topic: topic_with_users, allow_commands: false)
|
||||
|
||||
expect(rendered).not_to include("!tags")
|
||||
expect(rendered).not_to include("!search")
|
||||
|
||||
expect(persona.available_functions).to be_empty
|
||||
end
|
||||
|
||||
it "renders the system prompt" do
|
||||
|
@ -81,7 +81,7 @@ module DiscourseAi::AiBot::Personas
|
|||
# define an ai persona everyone can see
|
||||
persona =
|
||||
AiPersona.create!(
|
||||
name: "pun_bot",
|
||||
name: "zzzpun_bot",
|
||||
description: "you write puns",
|
||||
system_prompt: "you are pun bot",
|
||||
commands: ["ImageCommand"],
|
||||
|
@ -89,7 +89,7 @@ module DiscourseAi::AiBot::Personas
|
|||
)
|
||||
|
||||
custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last
|
||||
expect(custom_persona.name).to eq("pun_bot")
|
||||
expect(custom_persona.name).to eq("zzzpun_bot")
|
||||
expect(custom_persona.description).to eq("you write puns")
|
||||
|
||||
instance = custom_persona.new
|
||||
|
@ -99,53 +99,58 @@ module DiscourseAi::AiBot::Personas
|
|||
)
|
||||
|
||||
# should update
|
||||
persona.update!(name: "pun_bot2")
|
||||
persona.update!(name: "zzzpun_bot2")
|
||||
custom_persona = DiscourseAi::AiBot::Personas.all(user: user).last
|
||||
expect(custom_persona.name).to eq("pun_bot2")
|
||||
expect(custom_persona.name).to eq("zzzpun_bot2")
|
||||
|
||||
# can be disabled
|
||||
persona.update!(enabled: false)
|
||||
last_persona = DiscourseAi::AiBot::Personas.all(user: user).last
|
||||
expect(last_persona.name).not_to eq("pun_bot2")
|
||||
expect(last_persona.name).not_to eq("zzzpun_bot2")
|
||||
|
||||
persona.update!(enabled: true)
|
||||
# no groups have access
|
||||
persona.update!(allowed_group_ids: [])
|
||||
|
||||
last_persona = DiscourseAi::AiBot::Personas.all(user: user).last
|
||||
expect(last_persona.name).not_to eq("pun_bot2")
|
||||
expect(last_persona.name).not_to eq("zzzpun_bot2")
|
||||
end
|
||||
end
|
||||
|
||||
describe "available personas" do
|
||||
it "includes all personas by default" do
|
||||
Group.refresh_automatic_groups!
|
||||
|
||||
# must be enabled to see it
|
||||
SiteSetting.ai_stability_api_key = "abc"
|
||||
SiteSetting.ai_google_custom_search_api_key = "abc"
|
||||
SiteSetting.ai_google_custom_search_cx = "abc123"
|
||||
|
||||
expect(DiscourseAi::AiBot::Personas.all).to contain_exactly(
|
||||
General,
|
||||
SqlHelper,
|
||||
Artist,
|
||||
SettingsExplorer,
|
||||
Researcher,
|
||||
Creative,
|
||||
# should be ordered by priority and then alpha
|
||||
expect(DiscourseAi::AiBot::Personas.all(user: user)).to eq(
|
||||
[General, Artist, Creative, Researcher, SettingsExplorer, SqlHelper],
|
||||
)
|
||||
end
|
||||
|
||||
it "does not include personas that require api keys by default" do
|
||||
expect(DiscourseAi::AiBot::Personas.all).to contain_exactly(
|
||||
# omits personas if key is missing
|
||||
SiteSetting.ai_stability_api_key = ""
|
||||
SiteSetting.ai_google_custom_search_api_key = ""
|
||||
|
||||
expect(DiscourseAi::AiBot::Personas.all(user: user)).to contain_exactly(
|
||||
General,
|
||||
SqlHelper,
|
||||
SettingsExplorer,
|
||||
Creative,
|
||||
)
|
||||
end
|
||||
|
||||
it "can be modified via site settings" do
|
||||
SiteSetting.ai_bot_enabled_personas = "general|sql_helper"
|
||||
AiPersona.find(DiscourseAi::AiBot::Personas.system_personas[General]).update!(
|
||||
enabled: false,
|
||||
)
|
||||
|
||||
expect(DiscourseAi::AiBot::Personas.all).to contain_exactly(General, SqlHelper)
|
||||
expect(DiscourseAi::AiBot::Personas.all(user: user)).to contain_exactly(
|
||||
SqlHelper,
|
||||
SettingsExplorer,
|
||||
Creative,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiHelper::LlmPrompt do
|
||||
let(:prompt) { CompletionPrompt.find_by(name: mode, provider: "openai") }
|
||||
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../support/openai_completions_inference_stubs"
|
||||
require_relative "../../../support/stable_difussion_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiHelper::Painter do
|
||||
subject(:painter) { described_class.new }
|
||||
|
||||
|
@ -29,7 +26,7 @@ RSpec.describe DiscourseAi::AiHelper::Painter do
|
|||
|
||||
let(:expected_image_prompt) { <<~TEXT.strip }
|
||||
Visualize a vibrant scene of an inkwell bursting, spreading colors across a blank canvas,
|
||||
embodying words in tangible forms, symbolizing the rhythm and emotion evoked by poetry,
|
||||
embodying words in tangible forms, symbolizing the rhythm and emotion evoked by poetry,
|
||||
under the soft glow of a full moon.
|
||||
TEXT
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../support/embeddings_generation_stubs"
|
||||
require_relative "../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::Embeddings::SemanticSearch do
|
||||
fab!(:post) { Fabricate(:post) }
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/anthropic_completion_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::Summarization::Models::Anthropic do
|
||||
subject(:model) { described_class.new(model_name, max_tokens: max_tokens) }
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::Summarization::Models::OpenAi do
|
||||
subject(:model) { described_class.new(model_name, max_tokens: max_tokens) }
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ RSpec.describe AiPersona do
|
|||
|
||||
AiPersona.all_personas
|
||||
|
||||
expect(AiPersona.persona_cache[:value].length).to eq(1)
|
||||
expect(AiPersona.persona_cache[:value].length).to be > (0)
|
||||
RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" }
|
||||
expect(AiPersona.persona_cache[:value]).to eq(nil)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
# frozen_string_literal: true
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||
fab!(:admin)
|
||||
fab!(:ai_persona)
|
||||
|
||||
before { sign_in(admin) }
|
||||
|
||||
describe "GET #index" do
|
||||
it "returns a success response" do
|
||||
get "/admin/plugins/discourse-ai/ai_personas.json"
|
||||
expect(response).to be_successful
|
||||
|
||||
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
|
||||
expect(response.parsed_body["meta"]["commands"].length).to eq(
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_commands.length,
|
||||
)
|
||||
end
|
||||
|
||||
it "returns localized persona names and descriptions" do
|
||||
SiteSetting.default_locale = "fr"
|
||||
|
||||
get "/admin/plugins/discourse-ai/ai_personas.json"
|
||||
|
||||
TranslationOverride.upsert!(:fr, "discourse_ai.ai_bot.personas.general.name", "Général")
|
||||
TranslationOverride.upsert!(
|
||||
:fr,
|
||||
"discourse_ai.ai_bot.personas.general.description",
|
||||
"Général Description",
|
||||
)
|
||||
|
||||
id = DiscourseAi::AiBot::Personas.system_personas[DiscourseAi::AiBot::Personas::General]
|
||||
name = I18n.t("discourse_ai.ai_bot.personas.general.name")
|
||||
description = I18n.t("discourse_ai.ai_bot.personas.general.description")
|
||||
persona = response.parsed_body["ai_personas"].find { |p| p["id"] == id }
|
||||
|
||||
expect(persona["name"]).to eq(name)
|
||||
expect(persona["description"]).to eq(description)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET #show" do
|
||||
it "returns a success response" do
|
||||
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
|
||||
end
|
||||
|
||||
describe "POST #create" do
|
||||
context "with valid params" do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
name: "superbot",
|
||||
description: "Assists with tasks",
|
||||
system_prompt: "you are a helpful bot",
|
||||
}
|
||||
end
|
||||
|
||||
it "creates a new AiPersona" do
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai_personas.json",
|
||||
params: {
|
||||
ai_persona: valid_attributes,
|
||||
}
|
||||
expect(response).to be_successful
|
||||
}.to change(AiPersona, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.content_type).to include("application/json")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT #update" do
|
||||
context "with valid params" do
|
||||
it "updates the requested ai_persona" do
|
||||
put "/admin/plugins/discourse-ai/ai_personas/#{ai_persona.id}.json",
|
||||
params: {
|
||||
ai_persona: {
|
||||
name: "SuperBot",
|
||||
enabled: false,
|
||||
commands: ["search"],
|
||||
},
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.content_type).to include("application/json")
|
||||
|
||||
ai_persona.reload
|
||||
expect(ai_persona.name).to eq("SuperBot")
|
||||
expect(ai_persona.enabled).to eq(false)
|
||||
expect(ai_persona.commands).to eq(["search"])
|
||||
end
|
||||
end
|
||||
|
||||
context "with system personas" do
|
||||
it "does not allow editing of system prompts" do
|
||||
put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json",
|
||||
params: {
|
||||
ai_persona: {
|
||||
system_prompt: "you are not a helpful bot",
|
||||
},
|
||||
}
|
||||
|
||||
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 include("en.discourse")
|
||||
end
|
||||
|
||||
it "does not allow editing of commands" do
|
||||
put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json",
|
||||
params: {
|
||||
ai_persona: {
|
||||
commands: %w[SearchCommand ImageCommand],
|
||||
},
|
||||
}
|
||||
|
||||
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 include("en.discourse")
|
||||
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.system_personas.values.first}.json",
|
||||
params: {
|
||||
ai_persona: {
|
||||
name: "bob",
|
||||
dscription: "the bob",
|
||||
},
|
||||
}
|
||||
|
||||
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 include("en.discourse")
|
||||
end
|
||||
|
||||
it "does allow some actions" do
|
||||
put "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.system_personas.values.first}.json",
|
||||
params: {
|
||||
ai_persona: {
|
||||
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
|
||||
enabled: false,
|
||||
priority: 989,
|
||||
},
|
||||
}
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
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",
|
||||
params: {
|
||||
ai_persona: {
|
||||
name: "",
|
||||
},
|
||||
} # invalid attribute
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.content_type).to include("application/json")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE #destroy" do
|
||||
it "destroys the requested ai_persona" do
|
||||
expect {
|
||||
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)
|
||||
end
|
||||
|
||||
it "is not allowed to delete system personas" do
|
||||
expect {
|
||||
delete "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.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
|
||||
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
||||
}.not_to change(AiPersona, :count)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::AiHelper::AssistantController do
|
||||
describe "#suggest" do
|
||||
let(:text) { OpenAiCompletionsInferenceStubs.translated_response }
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../support/anthropic_completion_stubs"
|
||||
|
||||
RSpec.describe DiscourseAi::Inference::AnthropicCompletions do
|
||||
before { SiteSetting.ai_anthropic_api_key = "abc-123" }
|
||||
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
require "rails_helper"
|
||||
|
||||
require_relative "../../support/openai_completions_inference_stubs"
|
||||
|
||||
describe DiscourseAi::Inference::OpenAiCompletions do
|
||||
before { SiteSetting.ai_openai_api_key = "abc-123" }
|
||||
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
# frozen_string_literal: true
|
||||
RSpec.describe "AI personas", type: :system, js: true do
|
||||
fab!(:admin)
|
||||
|
||||
before do
|
||||
SiteSetting.ai_bot_enabled = true
|
||||
SiteSetting.ai_bot_enabled_chat_bots = "gpt-4|gpt-3.5-turbo"
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
it "allows creation of a persona" do
|
||||
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")
|
||||
find(".ai-persona-editor__system_prompt").fill_in(with: "You are a helpful bot")
|
||||
find(".ai-persona-editor__save").click()
|
||||
|
||||
expect(page).not_to have_current_path("/admin/plugins/discourse-ai/ai_personas/new")
|
||||
|
||||
persona_id = page.current_path.split("/").last.to_i
|
||||
|
||||
persona = AiPersona.find(persona_id)
|
||||
expect(persona.name).to eq("Test Persona")
|
||||
expect(persona.description).to eq("I am a test persona")
|
||||
expect(persona.system_prompt).to eq("You are a helpful bot")
|
||||
end
|
||||
|
||||
it "will not allow deletion or editing of system personas" do
|
||||
visit "/admin/plugins/discourse-ai/ai_personas/#{DiscourseAi::AiBot::Personas.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
|
||||
|
||||
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}"
|
||||
|
||||
find(".ai-persona-editor__name").set("Test Persona 1")
|
||||
PageObjects::Components::DToggleSwitch.new(".ai-persona-editor__enabled").toggle
|
||||
|
||||
try_until_success { expect(persona.reload.enabled).to eq(true) }
|
||||
|
||||
persona.reload
|
||||
expect(persona.enabled).to eq(true)
|
||||
expect(persona.name).not_to eq("Test Persona 1")
|
||||
end
|
||||
end
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||
fab!(:user) { Fabricate(:admin) }
|
||||
fab!(:non_member_group) { Fabricate(:group) }
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../support/openai_completions_inference_stubs"
|
||||
|
||||
RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||
fab!(:user) { Fabricate(:admin) }
|
||||
fab!(:non_member_group) { Fabricate(:group) }
|
||||
|
|
Loading…
Reference in New Issue