diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index ec4e19e1..56c96d56 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -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]) || diff --git a/app/controllers/discourse_ai/admin/ai_tools_controller.rb b/app/controllers/discourse_ai/admin/ai_tools_controller.rb index e4763278..e14ee011 100644 --- a/app/controllers/discourse_ai/admin/ai_tools_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_tools_controller.rb @@ -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 diff --git a/app/services/discourse_ai/persona_exporter.rb b/app/services/discourse_ai/persona_exporter.rb new file mode 100644 index 00000000..9beadc20 --- /dev/null +++ b/app/services/discourse_ai/persona_exporter.rb @@ -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 diff --git a/app/services/discourse_ai/persona_importer.rb b/app/services/discourse_ai/persona_importer.rb new file mode 100644 index 00000000..6fecc2ae --- /dev/null +++ b/app/services/discourse_ai/persona_importer.rb @@ -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 diff --git a/assets/javascripts/discourse/components/ai-persona-editor.gjs b/assets/javascripts/discourse/components/ai-persona-editor.gjs index f83b6cdf..d103caca 100644 --- a/assets/javascripts/discourse/components/ai-persona-editor.gjs +++ b/assets/javascripts/discourse/components/ai-persona-editor.gjs @@ -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); + } +