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:
Sam 2025-06-24 12:41:10 +10:00 committed by GitHub
parent eea96d6df9
commit 9f2a4094f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1081 additions and 17 deletions

View File

@ -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]) ||

View File

@ -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

View 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

View 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

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"}}

View File

@ -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)"

View File

@ -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>."

View File

@ -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"

View File

@ -223,7 +223,7 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
expect(persona.temperature).to eq(0.5)
}.to change(AiPersona, :count).by(1)
end
it "logs staff action when creating a persona" do
# Create the persona
post "/admin/plugins/discourse-ai/ai-personas.json",
@ -231,11 +231,15 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
headers: {
"CONTENT_TYPE" => "application/json",
}
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
@ -325,10 +329,10 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
expect(persona.top_p).to eq(nil)
expect(persona.temperature).to eq(nil)
end
it "logs staff action when updating a persona" do
persona = Fabricate(:ai_persona, name: "original_name", description: "original description")
# Update the persona
put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json",
params: {
@ -337,14 +341,18 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
description: "updated description",
},
}
expect(response).to have_http_status(:ok)
persona.reload
expect(persona.name).to eq("updated_name")
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 {
@ -500,18 +759,22 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do
expect(response).to have_http_status(:no_content)
}.to change(AiPersona, :count).by(-1)
end
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
delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json"
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

View File

@ -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
{

View 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

View 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