Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion app/controllers/discourse_ai/admin/ai_personas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Admin
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 =
Expand Down Expand Up @@ -105,6 +105,43 @@ def destroy
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]) ||
Expand Down
41 changes: 40 additions & 1 deletion app/controllers/discourse_ai/admin/ai_tools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Admin
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
Expand All @@ -32,6 +32,45 @@ def create
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

Expand Down
80 changes: 80 additions & 0 deletions app/services/discourse_ai/persona_exporter.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 149 additions & 0 deletions app/services/discourse_ai/persona_importer.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions assets/javascripts/discourse/components/ai-persona-editor.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading