mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-29 19:12:15 +00:00
FEATURE: persona/tool import and export (#1450)
Introduces import/export feature for tools and personas. Uploads are omitted for now, and will be added in a future PR * **Backend:** * Adds `import` and `export` actions to `Admin::AiPersonasController` and `Admin::AiToolsController`. * Introduces `DiscourseAi::PersonaExporter` and `DiscourseAi::PersonaImporter` services to manage JSON serialization and deserialization. * The export format for a persona embeds its associated custom tools. To ensure portability, `AiTool` references are serialized using their `tool_name` rather than their internal database `id`. * The import logic detects conflicts by name. A `force=true` parameter can be passed to overwrite existing records. * **Frontend:** * `AiPersonaListEditor` and `AiToolListEditor` components now include an "Import" button that handles file selection and POSTs the JSON data to the respective `import` endpoint. * `AiPersonaEditorForm` and `AiToolEditorForm` components feature an "Export" button that triggers a download of the serialized record. * Handles import conflicts (HTTP `409` for tools, `422` for personas) by showing a `dialog.confirm` prompt to allow the user to force an overwrite. * **Testing:** * Adds comprehensive request specs for the new controller actions (`#import`, `#export`). * Includes unit specs for the `PersonaExporter` and `PersonaImporter` services. * Persona import and export implemented
This commit is contained in:
parent
eea96d6df9
commit
9f2a4094f5
@ -5,7 +5,7 @@ module DiscourseAi
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
before_action :find_ai_persona, only: %i[edit update destroy create_user]
|
||||
before_action :find_ai_persona, only: %i[edit update destroy create_user export]
|
||||
|
||||
def index
|
||||
ai_personas =
|
||||
@ -105,6 +105,43 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
|
||||
def export
|
||||
persona = AiPersona.find(params[:id])
|
||||
exporter = DiscourseAi::PersonaExporter.new(persona: persona)
|
||||
|
||||
response.headers[
|
||||
"Content-Disposition"
|
||||
] = "attachment; filename=\"#{persona.name.parameterize}.json\""
|
||||
|
||||
render json: exporter.export
|
||||
end
|
||||
|
||||
def import
|
||||
name = params.dig(:persona, :name)
|
||||
existing_persona = AiPersona.find_by(name: name)
|
||||
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
|
||||
|
||||
begin
|
||||
importer = DiscourseAi::PersonaImporter.new(json: params.to_unsafe_h)
|
||||
|
||||
if existing_persona && force_update
|
||||
initial_attributes = existing_persona.attributes.dup
|
||||
persona = importer.import!(overwrite: true)
|
||||
log_ai_persona_update(persona, initial_attributes)
|
||||
render json: LocalizedAiPersonaSerializer.new(persona, root: false)
|
||||
else
|
||||
persona = importer.import!
|
||||
log_ai_persona_creation(persona)
|
||||
render json: LocalizedAiPersonaSerializer.new(persona, root: false), status: :created
|
||||
end
|
||||
rescue DiscourseAi::PersonaImporter::ImportError => e
|
||||
render_json_error e.message, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("AI Persona import failed: #{e.message}")
|
||||
render_json_error "Import failed: #{e.message}", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def stream_reply
|
||||
persona =
|
||||
AiPersona.find_by(name: params[:persona_name]) ||
|
||||
|
@ -5,7 +5,7 @@ module DiscourseAi
|
||||
class AiToolsController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
before_action :find_ai_tool, only: %i[test edit update destroy]
|
||||
before_action :find_ai_tool, only: %i[test edit update destroy export]
|
||||
|
||||
def index
|
||||
ai_tools = AiTool.all
|
||||
@ -32,6 +32,45 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
|
||||
def export
|
||||
response.headers[
|
||||
"Content-Disposition"
|
||||
] = "attachment; filename=\"#{@ai_tool.tool_name}.json\""
|
||||
render_serialized(@ai_tool, AiCustomToolSerializer)
|
||||
end
|
||||
|
||||
def import
|
||||
existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name])
|
||||
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
|
||||
|
||||
if existing_tool && !force_update
|
||||
return(
|
||||
render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.",
|
||||
status: :conflict
|
||||
)
|
||||
end
|
||||
|
||||
if existing_tool && force_update
|
||||
initial_attributes = existing_tool.attributes.dup
|
||||
if existing_tool.update(ai_tool_params)
|
||||
log_ai_tool_update(existing_tool, initial_attributes)
|
||||
render_serialized(existing_tool, AiCustomToolSerializer)
|
||||
else
|
||||
render_json_error existing_tool
|
||||
end
|
||||
else
|
||||
ai_tool = AiTool.new(ai_tool_params)
|
||||
ai_tool.created_by_id = current_user.id
|
||||
|
||||
if ai_tool.save
|
||||
log_ai_tool_creation(ai_tool)
|
||||
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
|
||||
else
|
||||
render_json_error ai_tool
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
initial_attributes = @ai_tool.attributes.dup
|
||||
|
||||
|
80
app/services/discourse_ai/persona_exporter.rb
Normal file
80
app/services/discourse_ai/persona_exporter.rb
Normal file
@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
class PersonaExporter
|
||||
def initialize(persona:)
|
||||
raise ArgumentError, "Invalid persona provided" if !persona.is_a?(AiPersona)
|
||||
@persona = persona
|
||||
end
|
||||
|
||||
def export
|
||||
serialized_custom_tools = serialize_tools(@persona)
|
||||
serialize_persona(@persona, serialized_custom_tools)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_tools(ai_persona)
|
||||
custom_tool_ids =
|
||||
(ai_persona.tools || []).filter_map do |tool_config|
|
||||
# A tool config is an array like: ["custom-ID", {options}, force_flag]
|
||||
if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
|
||||
tool_config[0].split("-", 2).last.to_i
|
||||
end
|
||||
end
|
||||
|
||||
return [] if custom_tool_ids.empty?
|
||||
|
||||
tools = AiTool.where(id: custom_tool_ids)
|
||||
tools.map do |tool|
|
||||
{
|
||||
identifier: tool.tool_name, # Use tool_name for portability
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
tool_name: tool.tool_name,
|
||||
parameters: tool.parameters,
|
||||
summary: tool.summary,
|
||||
script: tool.script,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def serialize_persona(ai_persona, serialized_custom_tools)
|
||||
export_data = {
|
||||
meta: {
|
||||
version: "1.0",
|
||||
exported_at: Time.zone.now.iso8601,
|
||||
},
|
||||
persona: {
|
||||
name: ai_persona.name,
|
||||
description: ai_persona.description,
|
||||
system_prompt: ai_persona.system_prompt,
|
||||
examples: ai_persona.examples,
|
||||
temperature: ai_persona.temperature,
|
||||
top_p: ai_persona.top_p,
|
||||
response_format: ai_persona.response_format,
|
||||
tools: transform_tools_for_export(ai_persona.tools, serialized_custom_tools),
|
||||
},
|
||||
custom_tools: serialized_custom_tools,
|
||||
}
|
||||
|
||||
JSON.pretty_generate(export_data)
|
||||
end
|
||||
|
||||
def transform_tools_for_export(tools_config, _serialized_custom_tools)
|
||||
return [] if tools_config.blank?
|
||||
|
||||
tools_config.map do |tool_config|
|
||||
unless tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
|
||||
next tool_config
|
||||
end
|
||||
|
||||
tool_id = tool_config[0].split("-", 2).last.to_i
|
||||
tool = AiTool.find_by(id: tool_id)
|
||||
next tool_config unless tool
|
||||
|
||||
["custom-#{tool.tool_name}", tool_config[1], tool_config[2]]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
149
app/services/discourse_ai/persona_importer.rb
Normal file
149
app/services/discourse_ai/persona_importer.rb
Normal file
@ -0,0 +1,149 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
class PersonaImporter
|
||||
class ImportError < StandardError
|
||||
attr_reader :conflicts
|
||||
|
||||
def initialize(message, conflicts = {})
|
||||
super(message)
|
||||
@conflicts = conflicts
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(json:)
|
||||
@data =
|
||||
case json
|
||||
when String
|
||||
JSON.parse(json)
|
||||
when Hash
|
||||
json
|
||||
else
|
||||
raise ArgumentError, "Invalid JSON payload"
|
||||
end
|
||||
|
||||
validate_payload!
|
||||
end
|
||||
|
||||
def import!(overwrite: false)
|
||||
ActiveRecord::Base.transaction do
|
||||
check_conflicts! unless overwrite
|
||||
|
||||
tool_name_to_id = import_custom_tools(@data["custom_tools"] || [], overwrite: overwrite)
|
||||
persona_data = @data["persona"]
|
||||
|
||||
existing_persona = AiPersona.find_by(name: persona_data["name"])
|
||||
|
||||
attrs = {
|
||||
description: persona_data["description"],
|
||||
system_prompt: persona_data["system_prompt"],
|
||||
examples: persona_data["examples"],
|
||||
temperature: persona_data["temperature"],
|
||||
top_p: persona_data["top_p"],
|
||||
response_format: persona_data["response_format"],
|
||||
tools: transform_tools_for_import(persona_data["tools"], tool_name_to_id),
|
||||
}
|
||||
|
||||
if existing_persona && overwrite
|
||||
existing_persona.update!(**attrs)
|
||||
existing_persona
|
||||
else
|
||||
attrs[:name] = persona_data["name"]
|
||||
AiPersona.create!(**attrs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_payload!
|
||||
unless @data.is_a?(Hash) && @data["persona"].is_a?(Hash)
|
||||
raise ArgumentError, "Invalid persona export data"
|
||||
end
|
||||
end
|
||||
|
||||
def check_conflicts!
|
||||
conflicts = {}
|
||||
|
||||
persona_name = @data["persona"]["name"]
|
||||
conflicts[:persona] = persona_name if AiPersona.exists?(name: persona_name)
|
||||
|
||||
if @data["custom_tools"].present?
|
||||
existing_tools = []
|
||||
@data["custom_tools"].each do |tool_data|
|
||||
tool_name = tool_data["tool_name"] || tool_data["identifier"]
|
||||
existing_tools << tool_name if AiTool.exists?(tool_name: tool_name)
|
||||
end
|
||||
conflicts[:custom_tools] = existing_tools if existing_tools.any?
|
||||
end
|
||||
|
||||
if conflicts.any?
|
||||
message = build_conflict_message(conflicts)
|
||||
raise ImportError.new(message, conflicts)
|
||||
end
|
||||
end
|
||||
|
||||
def build_conflict_message(conflicts)
|
||||
messages = []
|
||||
|
||||
if conflicts[:persona]
|
||||
messages << I18n.t("discourse_ai.errors.persona_already_exists", name: conflicts[:persona])
|
||||
end
|
||||
|
||||
if conflicts[:custom_tools] && conflicts[:custom_tools].any?
|
||||
tools_list = conflicts[:custom_tools].join(", ")
|
||||
error =
|
||||
I18n.t(
|
||||
"discourse_ai.errors.custom_tool_exists",
|
||||
names: tools_list,
|
||||
count: conflicts[:custom_tools].size,
|
||||
)
|
||||
messages << error
|
||||
end
|
||||
|
||||
messages.join("\n")
|
||||
end
|
||||
|
||||
def import_custom_tools(custom_tools, overwrite:)
|
||||
return {} if custom_tools.blank?
|
||||
|
||||
custom_tools.each_with_object({}) do |tool_data, map|
|
||||
tool_name = tool_data["tool_name"] || tool_data["identifier"]
|
||||
|
||||
if overwrite
|
||||
tool = AiTool.find_or_initialize_by(tool_name: tool_name)
|
||||
else
|
||||
tool = AiTool.new(tool_name: tool_name)
|
||||
end
|
||||
|
||||
tool.tap do |t|
|
||||
t.name = tool_data["name"]
|
||||
t.description = tool_data["description"]
|
||||
t.parameters = tool_data["parameters"]
|
||||
t.script = tool_data["script"]
|
||||
t.summary = tool_data["summary"]
|
||||
t.created_by ||= Discourse.system_user
|
||||
t.save!
|
||||
end
|
||||
|
||||
map[tool.tool_name] = tool.id
|
||||
end
|
||||
end
|
||||
|
||||
def transform_tools_for_import(tools_config, tool_name_to_id)
|
||||
return [] if tools_config.blank?
|
||||
|
||||
tools_config.map do |tool_config|
|
||||
if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
|
||||
tool_name = tool_config[0].split("-", 2).last
|
||||
tool_id = tool_name_to_id[tool_name] || AiTool.find_by(tool_name: tool_name)&.id
|
||||
raise ArgumentError, "Custom tool '#{tool_name}' not found" unless tool_id
|
||||
|
||||
["custom-#{tool_id}", tool_config[1], tool_config[2]]
|
||||
else
|
||||
tool_config
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -11,6 +11,7 @@ import BackButton from "discourse/components/back-button";
|
||||
import Form from "discourse/components/form";
|
||||
import Avatar from "discourse/helpers/bound-avatar-template";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import Group from "discourse/models/group";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AdminUser from "admin/models/admin-user";
|
||||
@ -292,6 +293,12 @@ export default class PersonaEditor extends Component {
|
||||
this.args.personas.setObjects(sorted);
|
||||
}
|
||||
|
||||
@action
|
||||
exportPersona() {
|
||||
const exportUrl = `/admin/plugins/discourse-ai/ai-personas/${this.args.model.id}/export.json`;
|
||||
window.location.href = getURL(exportUrl);
|
||||
}
|
||||
|
||||
<template>
|
||||
<BackButton
|
||||
@route="adminPlugins.show.discourse-ai-personas"
|
||||
@ -713,6 +720,11 @@ export default class PersonaEditor extends Component {
|
||||
<form.Submit />
|
||||
|
||||
{{#unless (or @model.isNew @model.system)}}
|
||||
<form.Button
|
||||
@label="discourse_ai.ai_persona.export"
|
||||
@action={{this.exportPersona}}
|
||||
class="ai-persona-editor__export"
|
||||
/>
|
||||
<form.Button
|
||||
@action={{this.delete}}
|
||||
@label="discourse_ai.ai_persona.delete"
|
||||
|
@ -14,10 +14,12 @@ import FilterInput from "discourse/components/filter-input";
|
||||
import avatar from "discourse/helpers/avatar";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
import AiPersona from "../admin/models/ai-persona";
|
||||
import AiPersonaEditor from "./ai-persona-editor";
|
||||
|
||||
const LAYOUT_BUTTONS = [
|
||||
@ -37,6 +39,7 @@ export default class AiPersonaListEditor extends Component {
|
||||
@service adminPluginNavManager;
|
||||
@service keyValueStore;
|
||||
@service capabilities;
|
||||
@service dialog;
|
||||
|
||||
@tracked filterValue = "";
|
||||
@tracked featureFilter = "all";
|
||||
@ -159,6 +162,77 @@ export default class AiPersonaListEditor extends Component {
|
||||
this.dMenu.close();
|
||||
}
|
||||
|
||||
@action
|
||||
importPersona() {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".json";
|
||||
fileInput.style.display = "none";
|
||||
fileInput.onchange = (event) => this.handleFileSelect(event);
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
|
||||
@action
|
||||
handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const json = JSON.parse(e.target.result);
|
||||
this.uploadPersona(json);
|
||||
} catch {
|
||||
this.dialog.alert(
|
||||
i18n("discourse_ai.ai_persona.import_error_not_json")
|
||||
);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
uploadPersona(personaData, force = false) {
|
||||
let url = `/admin/plugins/discourse-ai/ai-personas/import.json`;
|
||||
const payload = personaData;
|
||||
if (force) {
|
||||
payload.force = true;
|
||||
}
|
||||
|
||||
return ajax(url, {
|
||||
type: "POST",
|
||||
data: JSON.stringify(payload),
|
||||
contentType: "application/json",
|
||||
})
|
||||
.then((result) => {
|
||||
let persona = AiPersona.create(result);
|
||||
let existingPersona = this.args.personas.findBy("id", persona.id);
|
||||
if (existingPersona) {
|
||||
this.args.personas.removeObject(existingPersona);
|
||||
}
|
||||
this.args.personas.insertAt(0, persona);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.jqXHR?.status === 422) {
|
||||
this.dialog.confirm({
|
||||
message:
|
||||
i18n("discourse_ai.ai_persona.import_error_conflict", {
|
||||
name: personaData.persona.name,
|
||||
}) +
|
||||
"\n" +
|
||||
error.jqXHR.responseJSON?.errors?.join("\n"),
|
||||
confirmButtonLabel: "discourse_ai.ai_persona.overwrite",
|
||||
didConfirm: () => this.uploadPersona(personaData, true),
|
||||
});
|
||||
} else {
|
||||
popupAjaxError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<DBreadcrumbsItem
|
||||
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-personas"
|
||||
@ -176,6 +250,12 @@ export default class AiPersonaListEditor extends Component {
|
||||
@learnMoreUrl="https://meta.discourse.org/t/ai-bot-personas/306099"
|
||||
>
|
||||
<:actions as |actions|>
|
||||
<actions.Default
|
||||
@label="discourse_ai.ai_persona.import"
|
||||
@action={{this.importPersona}}
|
||||
@icon="upload"
|
||||
class="ai-persona-list-editor__import-button"
|
||||
/>
|
||||
<actions.Primary
|
||||
@label="discourse_ai.ai_persona.new"
|
||||
@route="adminPlugins.show.discourse-ai-personas.new"
|
||||
|
@ -6,6 +6,7 @@ import { service } from "@ember/service";
|
||||
import { and, gt } from "truth-helpers";
|
||||
import Form from "discourse/components/form";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AiToolTestModal from "./modal/ai-tool-test-modal";
|
||||
import RagOptionsFk from "./rag-options-fk";
|
||||
@ -151,6 +152,12 @@ export default class AiToolEditorForm extends Component {
|
||||
: i18n("discourse_ai.rag.uploads.description");
|
||||
}
|
||||
|
||||
@action
|
||||
exportTool() {
|
||||
const exportUrl = `/admin/plugins/discourse-ai/ai-tools/${this.args.model.id}/export.json`;
|
||||
window.location.href = getURL(exportUrl);
|
||||
}
|
||||
|
||||
<template>
|
||||
<Form
|
||||
@onSubmit={{this.save}}
|
||||
@ -386,7 +393,11 @@ export default class AiToolEditorForm extends Component {
|
||||
@action={{this.openTestModal}}
|
||||
class="ai-tool-editor__test-button"
|
||||
/>
|
||||
|
||||
<form.Button
|
||||
@label="discourse_ai.tools.export"
|
||||
@action={{this.exportTool}}
|
||||
class="ai-tool-editor__export"
|
||||
/>
|
||||
<form.Button
|
||||
@label="discourse_ai.tools.delete"
|
||||
@icon="trash-can"
|
||||
|
@ -8,13 +8,17 @@ import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DPageSubheader from "discourse/components/d-page-subheader";
|
||||
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
import AiTool from "../admin/models/ai-tool";
|
||||
|
||||
export default class AiToolListEditor extends Component {
|
||||
@service adminPluginNavManager;
|
||||
@service router;
|
||||
@service dialog;
|
||||
|
||||
get lastIndexOfPresets() {
|
||||
return this.args.tools.resultSetMeta.presets.length - 1;
|
||||
@ -32,6 +36,75 @@ export default class AiToolListEditor extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
importTool() {
|
||||
// Create a hidden file input and click it
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".json";
|
||||
fileInput.style.display = "none";
|
||||
fileInput.onchange = (event) => this.handleFileSelect(event);
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
|
||||
@action
|
||||
handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const json = JSON.parse(e.target.result);
|
||||
this.uploadTool(json.ai_tool);
|
||||
} catch {
|
||||
this.dialog.alert(i18n("discourse_ai.tools.import_error_not_json"));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
uploadTool(toolData, force = false) {
|
||||
let url = `/admin/plugins/discourse-ai/ai-tools/import.json`;
|
||||
const payload = {
|
||||
ai_tool: toolData,
|
||||
};
|
||||
if (force) {
|
||||
payload.force = true;
|
||||
}
|
||||
|
||||
return ajax(url, {
|
||||
type: "POST",
|
||||
data: JSON.stringify(payload),
|
||||
contentType: "application/json",
|
||||
})
|
||||
.then((result) => {
|
||||
let tool = AiTool.create(result.ai_tool);
|
||||
let existingTool = this.args.tools.findBy("id", tool.id);
|
||||
if (existingTool) {
|
||||
this.args.tools.removeObject(existingTool);
|
||||
}
|
||||
this.args.tools.insertAt(0, tool);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.jqXHR?.status === 409) {
|
||||
this.dialog.confirm({
|
||||
message: i18n("discourse_ai.tools.import_error_conflict", {
|
||||
name: toolData.tool_name,
|
||||
}),
|
||||
confirmButtonLabel: "discourse_ai.tools.overwrite",
|
||||
didConfirm: () => this.uploadTool(toolData, true),
|
||||
});
|
||||
} else {
|
||||
popupAjaxError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<DBreadcrumbsItem
|
||||
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-tools"
|
||||
@ -44,6 +117,12 @@ export default class AiToolListEditor extends Component {
|
||||
@descriptionLabel={{i18n "discourse_ai.tools.subheader_description"}}
|
||||
>
|
||||
<:actions>
|
||||
<DButton
|
||||
@translatedLabel={{i18n "discourse_ai.tools.import"}}
|
||||
@icon="upload"
|
||||
class="btn btn-small ai-tool-list-editor__import-button"
|
||||
@action={{this.importTool}}
|
||||
/>
|
||||
<DMenu
|
||||
@triggerClass="btn-primary btn-small ai-tool-list-editor__new-button"
|
||||
@label={{i18n "discourse_ai.tools.new"}}
|
||||
|
@ -328,6 +328,10 @@ en:
|
||||
back: "Back"
|
||||
name: "Name"
|
||||
edit: "Edit"
|
||||
export: "Export"
|
||||
import: "Import"
|
||||
import_error_conflict: "Conflict detected importing %{name}, would you like to update the existing persona?"
|
||||
overwrite: "Overwrite"
|
||||
description: "Description"
|
||||
no_llm_selected: "No language model selected"
|
||||
use_parent_llm: "Use personas language model"
|
||||
@ -445,6 +449,10 @@ en:
|
||||
tools:
|
||||
back: "Back"
|
||||
short_title: "Tools"
|
||||
export: "Export"
|
||||
import: "Import"
|
||||
import_error_conflict: "Tool already exists, would you like to update it?"
|
||||
overwrite: "Overwrite"
|
||||
no_tools: "You have not created any tools yet"
|
||||
name: "Name"
|
||||
name_help: "Name will show up in the Discourse UI and is the short identifier you will use to find the tool in various settings, it should be distinct (it is required)"
|
||||
|
@ -621,6 +621,10 @@ en:
|
||||
no_default_llm: The persona must have a default_llm defined.
|
||||
user_not_allowed: The user is not allowed to participate in the topic.
|
||||
prompt_message_length: The message %{idx} is over the 1000 character limit.
|
||||
persona_already_exists: Persona with the name %{name} already exists.
|
||||
custom_tool_exists:
|
||||
one: "Custom tool with the name %{names} already exists."
|
||||
other: "Custom tools with the names %{names} already exists."
|
||||
dashboard:
|
||||
problem:
|
||||
ai_llm_status: "The LLM model: %{model_name} is encountering issues. Please check the <a href='%{url}'>model's configuration page</a>."
|
||||
|
@ -85,8 +85,12 @@ Discourse::Application.routes.draw do
|
||||
)
|
||||
|
||||
post "/ai-tools/:id/test", to: "discourse_ai/admin/ai_tools#test"
|
||||
get "/ai-tools/:id/export", to: "discourse_ai/admin/ai_tools#export", format: :json
|
||||
post "/ai-tools/import", to: "discourse_ai/admin/ai_tools#import"
|
||||
|
||||
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
|
||||
get "/ai-personas/:id/export", to: "discourse_ai/admin/ai_personas#export", format: :json
|
||||
post "/ai-personas/import", to: "discourse_ai/admin/ai_personas#import"
|
||||
|
||||
put "/ai-personas/:id/files/remove", to: "discourse_ai/admin/ai_personas#remove_file"
|
||||
get "/ai-personas/:id/files/status", to: "discourse_ai/admin/ai_personas#indexing_status_check"
|
||||
|
@ -235,7 +235,11 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||
expect(response).to be_successful
|
||||
|
||||
# Now verify the log was created with the right subject
|
||||
history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "create_ai_persona").last
|
||||
history =
|
||||
UserHistory.where(
|
||||
action: UserHistory.actions[:custom_staff],
|
||||
custom_type: "create_ai_persona",
|
||||
).last
|
||||
expect(history).to be_present
|
||||
expect(history.subject).to eq("superbot") # Verify subject is set to name
|
||||
end
|
||||
@ -344,7 +348,11 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||
expect(persona.description).to eq("updated description")
|
||||
|
||||
# Now verify the log was created with the right subject
|
||||
history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "update_ai_persona").last
|
||||
history =
|
||||
UserHistory.where(
|
||||
action: UserHistory.actions[:custom_staff],
|
||||
custom_type: "update_ai_persona",
|
||||
).last
|
||||
expect(history).to be_present
|
||||
expect(history.subject).to eq("updated_name") # Verify subject is set to the new name
|
||||
end
|
||||
@ -492,6 +500,257 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET #export" do
|
||||
fab!(:ai_tool) do
|
||||
AiTool.create!(
|
||||
name: "Test Tool",
|
||||
tool_name: "test_tool",
|
||||
description: "A test tool",
|
||||
script: "function invoke(params) { return 'test'; }",
|
||||
parameters: [{ name: "query", type: "string", required: true }],
|
||||
summary: "Test tool summary",
|
||||
created_by_id: admin.id,
|
||||
)
|
||||
end
|
||||
|
||||
fab!(:persona_with_tools) do
|
||||
AiPersona.create!(
|
||||
name: "ToolMaster",
|
||||
description: "A persona with custom tools",
|
||||
system_prompt: "You are a tool master",
|
||||
tools: [
|
||||
["SearchCommand", { "base_query" => "test" }, true],
|
||||
["custom-#{ai_tool.id}", { "max_results" => 10 }, false],
|
||||
],
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
response_format: [{ type: "string", key: "summary" }],
|
||||
examples: [["user example", "assistant example"]],
|
||||
default_llm_id: llm_model.id,
|
||||
)
|
||||
end
|
||||
|
||||
it "exports a persona as JSON" do
|
||||
get "/admin/plugins/discourse-ai/ai-personas/#{persona_with_tools.id}/export.json"
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(response.headers["Content-Disposition"]).to include("attachment")
|
||||
expect(response.headers["Content-Disposition"]).to include("toolmaster.json")
|
||||
|
||||
json = response.parsed_body
|
||||
expect(json["meta"]["version"]).to eq("1.0")
|
||||
expect(json["meta"]["exported_at"]).to be_present
|
||||
|
||||
persona_data = json["persona"]
|
||||
expect(persona_data["name"]).to eq("ToolMaster")
|
||||
expect(persona_data["description"]).to eq("A persona with custom tools")
|
||||
expect(persona_data["system_prompt"]).to eq("You are a tool master")
|
||||
expect(persona_data["temperature"]).to eq(0.8)
|
||||
expect(persona_data["top_p"]).to eq(0.9)
|
||||
expect(persona_data["response_format"]).to eq([{ "type" => "string", "key" => "summary" }])
|
||||
expect(persona_data["examples"]).to eq([["user example", "assistant example"]])
|
||||
|
||||
# Check that custom tool ID is replaced with tool_name
|
||||
expect(persona_data["tools"]).to include(
|
||||
["SearchCommand", { "base_query" => "test" }, true],
|
||||
["custom-test_tool", { "max_results" => 10 }, false],
|
||||
)
|
||||
|
||||
# Check custom tools are exported
|
||||
expect(json["custom_tools"]).to be_an(Array)
|
||||
expect(json["custom_tools"].length).to eq(1)
|
||||
|
||||
custom_tool = json["custom_tools"].first
|
||||
expect(custom_tool["identifier"]).to eq("test_tool")
|
||||
expect(custom_tool["name"]).to eq("Test Tool")
|
||||
expect(custom_tool["description"]).to eq("A test tool")
|
||||
expect(custom_tool["script"]).to eq("function invoke(params) { return 'test'; }")
|
||||
expect(custom_tool["parameters"]).to eq(
|
||||
[{ "name" => "query", "type" => "string", "required" => true }],
|
||||
)
|
||||
end
|
||||
|
||||
it "handles personas without custom tools" do
|
||||
persona = Fabricate(:ai_persona, tools: ["SearchCommand"])
|
||||
|
||||
get "/admin/plugins/discourse-ai/ai-personas/#{persona.id}/export.json"
|
||||
|
||||
expect(response).to be_successful
|
||||
json = response.parsed_body
|
||||
expect(json["custom_tools"]).to eq([])
|
||||
expect(json["persona"]["tools"]).to eq(["SearchCommand"])
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent persona" do
|
||||
get "/admin/plugins/discourse-ai/ai-personas/999999/export.json"
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #import" do
|
||||
let(:valid_import_data) do
|
||||
{
|
||||
meta: {
|
||||
version: "1.0",
|
||||
exported_at: Time.zone.now.iso8601,
|
||||
},
|
||||
persona: {
|
||||
name: "ImportedPersona",
|
||||
description: "An imported persona",
|
||||
system_prompt: "You are an imported assistant",
|
||||
temperature: 0.7,
|
||||
top_p: 0.8,
|
||||
response_format: [{ type: "string", key: "answer" }],
|
||||
examples: [["hello", "hi there"]],
|
||||
tools: ["SearchCommand", ["ReadCommand", { max_length: 1000 }, true]],
|
||||
},
|
||||
custom_tools: [],
|
||||
}
|
||||
end
|
||||
|
||||
it "imports a new persona successfully" do
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json",
|
||||
params: valid_import_data,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:created)
|
||||
}.to change(AiPersona, :count).by(1)
|
||||
|
||||
persona = AiPersona.find_by(name: "ImportedPersona")
|
||||
expect(persona).to be_present
|
||||
expect(persona.description).to eq("An imported persona")
|
||||
expect(persona.system_prompt).to eq("You are an imported assistant")
|
||||
expect(persona.temperature).to eq(0.7)
|
||||
expect(persona.top_p).to eq(0.8)
|
||||
expect(persona.response_format).to eq([{ "type" => "string", "key" => "answer" }])
|
||||
expect(persona.examples).to eq([["hello", "hi there"]])
|
||||
expect(persona.tools).to eq(
|
||||
["SearchCommand", ["ReadCommand", { "max_length" => 1000 }, true]],
|
||||
)
|
||||
end
|
||||
|
||||
it "imports a persona with custom tools" do
|
||||
import_data_with_tools = valid_import_data.deep_dup
|
||||
import_data_with_tools[:persona][:tools] = [
|
||||
"SearchCommand",
|
||||
["custom-my_custom_tool", { param1: "value1" }, false],
|
||||
]
|
||||
import_data_with_tools[:custom_tools] = [
|
||||
{
|
||||
identifier: "my_custom_tool",
|
||||
name: "My Custom Tool",
|
||||
description: "A custom tool for testing",
|
||||
tool_name: "my_custom_tool",
|
||||
parameters: [{ name: "param1", type: "string", required: true }],
|
||||
summary: "Custom tool summary",
|
||||
script: "function invoke(params) { return params.param1; }",
|
||||
},
|
||||
]
|
||||
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json",
|
||||
params: import_data_with_tools,
|
||||
as: :json
|
||||
}.to change(AiPersona, :count).by(1).and change(AiTool, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
persona = AiPersona.find_by(name: "ImportedPersona")
|
||||
expect(persona).to be_present
|
||||
|
||||
tool = AiTool.find_by(tool_name: "my_custom_tool")
|
||||
expect(tool).to be_present
|
||||
expect(tool.name).to eq("My Custom Tool")
|
||||
expect(tool.description).to eq("A custom tool for testing")
|
||||
expect(tool.script).to eq("function invoke(params) { return params.param1; }")
|
||||
|
||||
# Check that the tool reference uses the ID
|
||||
expect(persona.tools).to include(
|
||||
"SearchCommand",
|
||||
["custom-#{tool.id}", { "param1" => "value1" }, false],
|
||||
)
|
||||
end
|
||||
|
||||
it "prevents importing duplicate personas by default" do
|
||||
_existing_persona = Fabricate(:ai_persona, name: "ImportedPersona")
|
||||
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json",
|
||||
params: valid_import_data,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body["errors"].join).to include("ImportedPersona")
|
||||
end
|
||||
|
||||
it "allows overwriting existing personas with force=true" do
|
||||
existing_persona =
|
||||
Fabricate(:ai_persona, name: "ImportedPersona", description: "Old description")
|
||||
|
||||
import_data = valid_import_data.merge(force: true)
|
||||
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json", params: import_data, as: :json
|
||||
}.not_to change(AiPersona, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
existing_persona.reload
|
||||
expect(existing_persona.description).to eq("An imported persona")
|
||||
expect(existing_persona.system_prompt).to eq("You are an imported assistant")
|
||||
end
|
||||
|
||||
it "handles invalid import data gracefully" do
|
||||
invalid_data = { invalid: "data" }
|
||||
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json", params: invalid_data, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body["errors"]).to be_present
|
||||
end
|
||||
|
||||
it "handles missing persona data" do
|
||||
invalid_data = { meta: { version: "1.0" } }
|
||||
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json", params: invalid_data, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "logs staff action when importing a new persona" do
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json",
|
||||
params: valid_import_data,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
history =
|
||||
UserHistory.where(
|
||||
action: UserHistory.actions[:custom_staff],
|
||||
custom_type: "create_ai_persona",
|
||||
).last
|
||||
expect(history).to be_present
|
||||
expect(history.subject).to eq("ImportedPersona")
|
||||
end
|
||||
|
||||
it "logs staff action when updating an existing persona" do
|
||||
_existing_persona = Fabricate(:ai_persona, name: "ImportedPersona")
|
||||
|
||||
import_data = valid_import_data.merge(force: true)
|
||||
|
||||
post "/admin/plugins/discourse-ai/ai-personas/import.json", params: import_data, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
history =
|
||||
UserHistory.where(
|
||||
action: UserHistory.actions[:custom_staff],
|
||||
custom_type: "update_ai_persona",
|
||||
).last
|
||||
expect(history).to be_present
|
||||
expect(history.subject).to eq("ImportedPersona")
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE #destroy" do
|
||||
it "destroys the requested ai_persona" do
|
||||
expect {
|
||||
@ -503,7 +762,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||
|
||||
it "logs staff action when deleting a persona" do
|
||||
# Capture persona details before deletion
|
||||
persona_id = ai_persona.id
|
||||
_persona_id = ai_persona.id
|
||||
persona_name = ai_persona.name
|
||||
|
||||
# Delete the persona
|
||||
@ -511,7 +770,11 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
||||
expect(response).to have_http_status(:no_content)
|
||||
|
||||
# Now verify the log was created with the right subject
|
||||
history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "delete_ai_persona").last
|
||||
history =
|
||||
UserHistory.where(
|
||||
action: UserHistory.actions[:custom_staff],
|
||||
custom_type: "delete_ai_persona",
|
||||
).last
|
||||
expect(history).to be_present
|
||||
expect(history.subject).to eq(persona_name) # Verify subject is set to name
|
||||
end
|
||||
|
@ -46,6 +46,107 @@ RSpec.describe DiscourseAi::Admin::AiToolsController do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET #export" do
|
||||
it "returns the ai_tool as JSON attachment" do
|
||||
get "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/export.json"
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(response.headers["Content-Disposition"]).to eq(
|
||||
"attachment; filename=\"#{ai_tool.tool_name}.json\"",
|
||||
)
|
||||
expect(response.parsed_body["ai_tool"]["name"]).to eq(ai_tool.name)
|
||||
expect(response.parsed_body["ai_tool"]["tool_name"]).to eq(ai_tool.tool_name)
|
||||
expect(response.parsed_body["ai_tool"]["description"]).to eq(ai_tool.description)
|
||||
expect(response.parsed_body["ai_tool"]["parameters"]).to eq(ai_tool.parameters)
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent ai_tool" do
|
||||
get "/admin/plugins/discourse-ai/ai-tools/99999/export.json"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #import" do
|
||||
let(:import_attributes) do
|
||||
{
|
||||
name: "Imported Tool",
|
||||
tool_name: "imported_tool",
|
||||
description: "An imported test tool",
|
||||
parameters: [{ name: "query", type: "string", description: "perform a search" }],
|
||||
script: "function invoke(params) { return params; }",
|
||||
summary: "Imported tool summary",
|
||||
}
|
||||
end
|
||||
|
||||
it "imports a new AI tool successfully" do
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai-tools/import.json",
|
||||
params: { ai_tool: import_attributes }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
}
|
||||
}.to change(AiTool, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(response.parsed_body["ai_tool"]["name"]).to eq("Imported Tool")
|
||||
expect(response.parsed_body["ai_tool"]["tool_name"]).to eq("imported_tool")
|
||||
end
|
||||
|
||||
it "returns conflict error when tool with same tool_name exists without force" do
|
||||
_existing_tool =
|
||||
AiTool.create!(
|
||||
name: "Existing Tool",
|
||||
tool_name: "imported_tool",
|
||||
description: "Existing tool",
|
||||
script: "function invoke(params) { return 'existing'; }",
|
||||
summary: "Existing summary",
|
||||
created_by_id: admin.id,
|
||||
)
|
||||
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai-tools/import.json",
|
||||
params: { ai_tool: import_attributes }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
}
|
||||
}.not_to change(AiTool, :count)
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
expect(response.parsed_body["errors"]).to include(
|
||||
"Tool with tool_name 'imported_tool' already exists. Use force=true to overwrite.",
|
||||
)
|
||||
end
|
||||
|
||||
it "force updates existing tool when force=true" do
|
||||
existing_tool =
|
||||
AiTool.create!(
|
||||
name: "Existing Tool",
|
||||
tool_name: "imported_tool",
|
||||
description: "Existing tool",
|
||||
script: "function invoke(params) { return 'existing'; }",
|
||||
summary: "Existing summary",
|
||||
created_by_id: admin.id,
|
||||
)
|
||||
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/ai-tools/import.json?force=true",
|
||||
params: { ai_tool: import_attributes }.to_json,
|
||||
headers: {
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
}
|
||||
}.not_to change(AiTool, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body["ai_tool"]["name"]).to eq("Imported Tool")
|
||||
expect(response.parsed_body["ai_tool"]["description"]).to eq("An imported test tool")
|
||||
|
||||
existing_tool.reload
|
||||
expect(existing_tool.name).to eq("Imported Tool")
|
||||
expect(existing_tool.description).to eq("An imported test tool")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST #create" do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
|
41
spec/services/discourse_ai/persona_exporter_spec.rb
Normal file
41
spec/services/discourse_ai/persona_exporter_spec.rb
Normal file
@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::PersonaExporter do
|
||||
describe "#export" do
|
||||
subject(:export_json) { JSON.parse(exporter.export) }
|
||||
|
||||
context "when exporting a persona with a custom tool" do
|
||||
fab!(:ai_tool) { Fabricate(:ai_tool, name: "Giphy Searcher", tool_name: "giphy_search") }
|
||||
fab!(:ai_persona) { Fabricate(:ai_persona, tools: [["custom-#{ai_tool.id}", nil, false]]) }
|
||||
|
||||
let(:exporter) { described_class.new(persona: ai_persona) }
|
||||
|
||||
it "returns JSON containing the persona and its custom tool" do
|
||||
expect(export_json["persona"]["name"]).to eq(ai_persona.name)
|
||||
expect(export_json["persona"]["tools"].first.first).to eq("custom-#{ai_tool.tool_name}")
|
||||
|
||||
custom_tool = export_json["custom_tools"].first
|
||||
expect(custom_tool["identifier"]).to eq(ai_tool.tool_name)
|
||||
expect(custom_tool["name"]).to eq(ai_tool.name)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the persona has no custom tools" do
|
||||
fab!(:ai_persona) { Fabricate(:ai_persona, tools: []) }
|
||||
let(:exporter) { described_class.new(persona: ai_persona) }
|
||||
|
||||
it "returns JSON with an empty custom_tools array" do
|
||||
expect(export_json["custom_tools"]).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context "when the persona does not exist" do
|
||||
it "raises an error if initialized with a non persona" do
|
||||
expect { described_class.new(persona: nil) }.to raise_error(
|
||||
ArgumentError,
|
||||
"Invalid persona provided",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
156
spec/services/discourse_ai/persona_importer_spec.rb
Normal file
156
spec/services/discourse_ai/persona_importer_spec.rb
Normal file
@ -0,0 +1,156 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::PersonaImporter do
|
||||
describe "#import!" do
|
||||
context "when importing a persona with a custom tool" do
|
||||
fab!(:ai_tool) { Fabricate(:ai_tool, name: "Giphy Searcher", tool_name: "giphy_search") }
|
||||
fab!(:ai_persona) { Fabricate(:ai_persona, tools: [["custom-#{ai_tool.id}", nil, false]]) }
|
||||
|
||||
let!(:export_json) { DiscourseAi::PersonaExporter.new(persona: ai_persona).export }
|
||||
|
||||
it "creates the persona and its custom tool" do
|
||||
ai_persona.destroy
|
||||
ai_tool.destroy
|
||||
|
||||
importer = described_class.new(json: export_json)
|
||||
persona = importer.import!
|
||||
|
||||
expect(persona).to be_persisted
|
||||
expect(persona.tools.first.first).to start_with("custom-")
|
||||
|
||||
tool_id = persona.tools.first.first.split("-", 2).last.to_i
|
||||
imported_tool = AiTool.find(tool_id)
|
||||
|
||||
expect(imported_tool.tool_name).to eq("giphy_search")
|
||||
expect(imported_tool.name).to eq("Giphy Searcher")
|
||||
end
|
||||
end
|
||||
|
||||
context "when conflicts exist" do
|
||||
fab!(:existing_tool) { Fabricate(:ai_tool, name: "Web Browser", tool_name: "browse_web") }
|
||||
fab!(:another_tool) { Fabricate(:ai_tool, name: "Calculator", tool_name: "calculator") }
|
||||
fab!(:ai_persona) do
|
||||
Fabricate(
|
||||
:ai_persona,
|
||||
name: "Test Persona",
|
||||
tools: [
|
||||
["custom-#{existing_tool.id}", nil, false],
|
||||
["custom-#{another_tool.id}", nil, false],
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
let(:export_json) { DiscourseAi::PersonaExporter.new(persona: ai_persona).export }
|
||||
|
||||
context "when persona already exists" do
|
||||
it "raises ImportError with persona conflict details" do
|
||||
importer = described_class.new(json: export_json)
|
||||
|
||||
expect { importer.import! }.to raise_error(
|
||||
DiscourseAi::PersonaImporter::ImportError,
|
||||
) do |error|
|
||||
expect(error.conflicts).to eq(
|
||||
persona: "Test Persona",
|
||||
custom_tools: %w[browse_web calculator],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when custom tools already exist" do
|
||||
before { ai_persona.destroy }
|
||||
|
||||
it "raises ImportError with custom tools conflict details" do
|
||||
importer = described_class.new(json: export_json)
|
||||
|
||||
expect { importer.import! }.to raise_error(
|
||||
DiscourseAi::PersonaImporter::ImportError,
|
||||
) do |error|
|
||||
expect(error.conflicts).to eq(custom_tools: %w[browse_web calculator])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when both persona and custom tools exist" do
|
||||
it "raises ImportError with all conflicts" do
|
||||
importer = described_class.new(json: export_json)
|
||||
|
||||
expect { importer.import! }.to raise_error(
|
||||
DiscourseAi::PersonaImporter::ImportError,
|
||||
) do |error|
|
||||
expect(error.conflicts).to eq(
|
||||
persona: "Test Persona",
|
||||
custom_tools: %w[browse_web calculator],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with overwrite: true" do
|
||||
fab!(:existing_tool) { Fabricate(:ai_tool, name: "Old Browser", tool_name: "browse_web") }
|
||||
fab!(:existing_persona) do
|
||||
Fabricate(
|
||||
:ai_persona,
|
||||
name: "Test Persona",
|
||||
description: "Old description",
|
||||
system_prompt: "Old prompt",
|
||||
tools: [],
|
||||
)
|
||||
end
|
||||
fab!(:new_tool) { Fabricate(:ai_tool, name: "New Tool", tool_name: "new_tool") }
|
||||
let(:export_persona) do
|
||||
Fabricate.build(
|
||||
:ai_persona,
|
||||
name: "Test Persona",
|
||||
description: "New description",
|
||||
system_prompt: "New prompt",
|
||||
tools: [
|
||||
["custom-#{existing_tool.id}", nil, false],
|
||||
["custom-#{new_tool.id}", nil, false],
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
let(:export_json) { DiscourseAi::PersonaExporter.new(persona: export_persona).export }
|
||||
|
||||
before { export_persona.destroy }
|
||||
|
||||
it "overwrites existing persona" do
|
||||
importer = described_class.new(json: export_json)
|
||||
persona = importer.import!(overwrite: true)
|
||||
|
||||
expect(persona.id).to eq(existing_persona.id)
|
||||
expect(persona.description).to eq("New description")
|
||||
expect(persona.system_prompt).to eq("New prompt")
|
||||
expect(persona.tools.length).to eq(2)
|
||||
end
|
||||
|
||||
it "overwrites existing custom tools" do
|
||||
existing_tool.update!(name: "Old Browser", description: "Old description")
|
||||
|
||||
importer = described_class.new(json: export_json)
|
||||
expect { importer.import!(overwrite: true) }.not_to change { AiTool.count }
|
||||
|
||||
existing_tool.reload
|
||||
expect(existing_tool.name).to eq("Old Browser") # Name from export_json
|
||||
end
|
||||
end
|
||||
|
||||
context "with invalid payload" do
|
||||
it "raises an error for invalid JSON structure" do
|
||||
expect { described_class.new(json: "{}").import! }.to raise_error(
|
||||
ArgumentError,
|
||||
"Invalid persona export data",
|
||||
)
|
||||
end
|
||||
|
||||
it "raises an error for missing persona data" do
|
||||
expect { described_class.new(json: { "custom_tools" => [] }).import! }.to raise_error(
|
||||
ArgumentError,
|
||||
"Invalid persona export data",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user