mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-08-01 10:53:27 +00:00
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
150 lines
4.2 KiB
Ruby
150 lines
4.2 KiB
Ruby
# 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
|