Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 9f2a409

Browse files
authored
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
1 parent eea96d6 commit 9f2a409

File tree

15 files changed

+1081
-17
lines changed

15 files changed

+1081
-17
lines changed

app/controllers/discourse_ai/admin/ai_personas_controller.rb

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Admin
55
class AiPersonasController < ::Admin::AdminController
66
requires_plugin ::DiscourseAi::PLUGIN_NAME
77

8-
before_action :find_ai_persona, only: %i[edit update destroy create_user]
8+
before_action :find_ai_persona, only: %i[edit update destroy create_user export]
99

1010
def index
1111
ai_personas =
@@ -105,6 +105,43 @@ def destroy
105105
end
106106
end
107107

108+
def export
109+
persona = AiPersona.find(params[:id])
110+
exporter = DiscourseAi::PersonaExporter.new(persona: persona)
111+
112+
response.headers[
113+
"Content-Disposition"
114+
] = "attachment; filename=\"#{persona.name.parameterize}.json\""
115+
116+
render json: exporter.export
117+
end
118+
119+
def import
120+
name = params.dig(:persona, :name)
121+
existing_persona = AiPersona.find_by(name: name)
122+
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
123+
124+
begin
125+
importer = DiscourseAi::PersonaImporter.new(json: params.to_unsafe_h)
126+
127+
if existing_persona && force_update
128+
initial_attributes = existing_persona.attributes.dup
129+
persona = importer.import!(overwrite: true)
130+
log_ai_persona_update(persona, initial_attributes)
131+
render json: LocalizedAiPersonaSerializer.new(persona, root: false)
132+
else
133+
persona = importer.import!
134+
log_ai_persona_creation(persona)
135+
render json: LocalizedAiPersonaSerializer.new(persona, root: false), status: :created
136+
end
137+
rescue DiscourseAi::PersonaImporter::ImportError => e
138+
render_json_error e.message, status: :unprocessable_entity
139+
rescue StandardError => e
140+
Rails.logger.error("AI Persona import failed: #{e.message}")
141+
render_json_error "Import failed: #{e.message}", status: :unprocessable_entity
142+
end
143+
end
144+
108145
def stream_reply
109146
persona =
110147
AiPersona.find_by(name: params[:persona_name]) ||

app/controllers/discourse_ai/admin/ai_tools_controller.rb

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Admin
55
class AiToolsController < ::Admin::AdminController
66
requires_plugin ::DiscourseAi::PLUGIN_NAME
77

8-
before_action :find_ai_tool, only: %i[test edit update destroy]
8+
before_action :find_ai_tool, only: %i[test edit update destroy export]
99

1010
def index
1111
ai_tools = AiTool.all
@@ -32,6 +32,45 @@ def create
3232
end
3333
end
3434

35+
def export
36+
response.headers[
37+
"Content-Disposition"
38+
] = "attachment; filename=\"#{@ai_tool.tool_name}.json\""
39+
render_serialized(@ai_tool, AiCustomToolSerializer)
40+
end
41+
42+
def import
43+
existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name])
44+
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
45+
46+
if existing_tool && !force_update
47+
return(
48+
render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.",
49+
status: :conflict
50+
)
51+
end
52+
53+
if existing_tool && force_update
54+
initial_attributes = existing_tool.attributes.dup
55+
if existing_tool.update(ai_tool_params)
56+
log_ai_tool_update(existing_tool, initial_attributes)
57+
render_serialized(existing_tool, AiCustomToolSerializer)
58+
else
59+
render_json_error existing_tool
60+
end
61+
else
62+
ai_tool = AiTool.new(ai_tool_params)
63+
ai_tool.created_by_id = current_user.id
64+
65+
if ai_tool.save
66+
log_ai_tool_creation(ai_tool)
67+
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
68+
else
69+
render_json_error ai_tool
70+
end
71+
end
72+
end
73+
3574
def update
3675
initial_attributes = @ai_tool.attributes.dup
3776

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
class PersonaExporter
5+
def initialize(persona:)
6+
raise ArgumentError, "Invalid persona provided" if !persona.is_a?(AiPersona)
7+
@persona = persona
8+
end
9+
10+
def export
11+
serialized_custom_tools = serialize_tools(@persona)
12+
serialize_persona(@persona, serialized_custom_tools)
13+
end
14+
15+
private
16+
17+
def serialize_tools(ai_persona)
18+
custom_tool_ids =
19+
(ai_persona.tools || []).filter_map do |tool_config|
20+
# A tool config is an array like: ["custom-ID", {options}, force_flag]
21+
if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
22+
tool_config[0].split("-", 2).last.to_i
23+
end
24+
end
25+
26+
return [] if custom_tool_ids.empty?
27+
28+
tools = AiTool.where(id: custom_tool_ids)
29+
tools.map do |tool|
30+
{
31+
identifier: tool.tool_name, # Use tool_name for portability
32+
name: tool.name,
33+
description: tool.description,
34+
tool_name: tool.tool_name,
35+
parameters: tool.parameters,
36+
summary: tool.summary,
37+
script: tool.script,
38+
}
39+
end
40+
end
41+
42+
def serialize_persona(ai_persona, serialized_custom_tools)
43+
export_data = {
44+
meta: {
45+
version: "1.0",
46+
exported_at: Time.zone.now.iso8601,
47+
},
48+
persona: {
49+
name: ai_persona.name,
50+
description: ai_persona.description,
51+
system_prompt: ai_persona.system_prompt,
52+
examples: ai_persona.examples,
53+
temperature: ai_persona.temperature,
54+
top_p: ai_persona.top_p,
55+
response_format: ai_persona.response_format,
56+
tools: transform_tools_for_export(ai_persona.tools, serialized_custom_tools),
57+
},
58+
custom_tools: serialized_custom_tools,
59+
}
60+
61+
JSON.pretty_generate(export_data)
62+
end
63+
64+
def transform_tools_for_export(tools_config, _serialized_custom_tools)
65+
return [] if tools_config.blank?
66+
67+
tools_config.map do |tool_config|
68+
unless tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
69+
next tool_config
70+
end
71+
72+
tool_id = tool_config[0].split("-", 2).last.to_i
73+
tool = AiTool.find_by(id: tool_id)
74+
next tool_config unless tool
75+
76+
["custom-#{tool.tool_name}", tool_config[1], tool_config[2]]
77+
end
78+
end
79+
end
80+
end
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
class PersonaImporter
5+
class ImportError < StandardError
6+
attr_reader :conflicts
7+
8+
def initialize(message, conflicts = {})
9+
super(message)
10+
@conflicts = conflicts
11+
end
12+
end
13+
14+
def initialize(json:)
15+
@data =
16+
case json
17+
when String
18+
JSON.parse(json)
19+
when Hash
20+
json
21+
else
22+
raise ArgumentError, "Invalid JSON payload"
23+
end
24+
25+
validate_payload!
26+
end
27+
28+
def import!(overwrite: false)
29+
ActiveRecord::Base.transaction do
30+
check_conflicts! unless overwrite
31+
32+
tool_name_to_id = import_custom_tools(@data["custom_tools"] || [], overwrite: overwrite)
33+
persona_data = @data["persona"]
34+
35+
existing_persona = AiPersona.find_by(name: persona_data["name"])
36+
37+
attrs = {
38+
description: persona_data["description"],
39+
system_prompt: persona_data["system_prompt"],
40+
examples: persona_data["examples"],
41+
temperature: persona_data["temperature"],
42+
top_p: persona_data["top_p"],
43+
response_format: persona_data["response_format"],
44+
tools: transform_tools_for_import(persona_data["tools"], tool_name_to_id),
45+
}
46+
47+
if existing_persona && overwrite
48+
existing_persona.update!(**attrs)
49+
existing_persona
50+
else
51+
attrs[:name] = persona_data["name"]
52+
AiPersona.create!(**attrs)
53+
end
54+
end
55+
end
56+
57+
private
58+
59+
def validate_payload!
60+
unless @data.is_a?(Hash) && @data["persona"].is_a?(Hash)
61+
raise ArgumentError, "Invalid persona export data"
62+
end
63+
end
64+
65+
def check_conflicts!
66+
conflicts = {}
67+
68+
persona_name = @data["persona"]["name"]
69+
conflicts[:persona] = persona_name if AiPersona.exists?(name: persona_name)
70+
71+
if @data["custom_tools"].present?
72+
existing_tools = []
73+
@data["custom_tools"].each do |tool_data|
74+
tool_name = tool_data["tool_name"] || tool_data["identifier"]
75+
existing_tools << tool_name if AiTool.exists?(tool_name: tool_name)
76+
end
77+
conflicts[:custom_tools] = existing_tools if existing_tools.any?
78+
end
79+
80+
if conflicts.any?
81+
message = build_conflict_message(conflicts)
82+
raise ImportError.new(message, conflicts)
83+
end
84+
end
85+
86+
def build_conflict_message(conflicts)
87+
messages = []
88+
89+
if conflicts[:persona]
90+
messages << I18n.t("discourse_ai.errors.persona_already_exists", name: conflicts[:persona])
91+
end
92+
93+
if conflicts[:custom_tools] && conflicts[:custom_tools].any?
94+
tools_list = conflicts[:custom_tools].join(", ")
95+
error =
96+
I18n.t(
97+
"discourse_ai.errors.custom_tool_exists",
98+
names: tools_list,
99+
count: conflicts[:custom_tools].size,
100+
)
101+
messages << error
102+
end
103+
104+
messages.join("\n")
105+
end
106+
107+
def import_custom_tools(custom_tools, overwrite:)
108+
return {} if custom_tools.blank?
109+
110+
custom_tools.each_with_object({}) do |tool_data, map|
111+
tool_name = tool_data["tool_name"] || tool_data["identifier"]
112+
113+
if overwrite
114+
tool = AiTool.find_or_initialize_by(tool_name: tool_name)
115+
else
116+
tool = AiTool.new(tool_name: tool_name)
117+
end
118+
119+
tool.tap do |t|
120+
t.name = tool_data["name"]
121+
t.description = tool_data["description"]
122+
t.parameters = tool_data["parameters"]
123+
t.script = tool_data["script"]
124+
t.summary = tool_data["summary"]
125+
t.created_by ||= Discourse.system_user
126+
t.save!
127+
end
128+
129+
map[tool.tool_name] = tool.id
130+
end
131+
end
132+
133+
def transform_tools_for_import(tools_config, tool_name_to_id)
134+
return [] if tools_config.blank?
135+
136+
tools_config.map do |tool_config|
137+
if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
138+
tool_name = tool_config[0].split("-", 2).last
139+
tool_id = tool_name_to_id[tool_name] || AiTool.find_by(tool_name: tool_name)&.id
140+
raise ArgumentError, "Custom tool '#{tool_name}' not found" unless tool_id
141+
142+
["custom-#{tool_id}", tool_config[1], tool_config[2]]
143+
else
144+
tool_config
145+
end
146+
end
147+
end
148+
end
149+
end

assets/javascripts/discourse/components/ai-persona-editor.gjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import BackButton from "discourse/components/back-button";
1111
import Form from "discourse/components/form";
1212
import Avatar from "discourse/helpers/bound-avatar-template";
1313
import { popupAjaxError } from "discourse/lib/ajax-error";
14+
import getURL from "discourse/lib/get-url";
1415
import Group from "discourse/models/group";
1516
import { i18n } from "discourse-i18n";
1617
import AdminUser from "admin/models/admin-user";
@@ -292,6 +293,12 @@ export default class PersonaEditor extends Component {
292293
this.args.personas.setObjects(sorted);
293294
}
294295

296+
@action
297+
exportPersona() {
298+
const exportUrl = `/admin/plugins/discourse-ai/ai-personas/${this.args.model.id}/export.json`;
299+
window.location.href = getURL(exportUrl);
300+
}
301+
295302
<template>
296303
<BackButton
297304
@route="adminPlugins.show.discourse-ai-personas"
@@ -713,6 +720,11 @@ export default class PersonaEditor extends Component {
713720
<form.Submit />
714721

715722
{{#unless (or @model.isNew @model.system)}}
723+
<form.Button
724+
@label="discourse_ai.ai_persona.export"
725+
@action={{this.exportPersona}}
726+
class="ai-persona-editor__export"
727+
/>
716728
<form.Button
717729
@action={{this.delete}}
718730
@label="discourse_ai.ai_persona.delete"

0 commit comments

Comments
 (0)