Skip to content

Commit b863ddc

Browse files
FEATURE: custom user defined tools (#677)
Introduces custom AI tools functionality. 1. Why it was added: The PR adds the ability to create, manage, and use custom AI tools within the Discourse AI system. This feature allows for more flexibility and extensibility in the AI capabilities of the platform. 2. What it does: - Introduces a new `AiTool` model for storing custom AI tools - Adds CRUD (Create, Read, Update, Delete) operations for AI tools - Implements a tool runner system for executing custom tool scripts - Integrates custom tools with existing AI personas - Provides a user interface for managing custom tools in the admin panel 3. Possible use cases: - Creating custom tools for specific tasks or integrations (stock quotes, currency conversion etc...) - Allowing administrators to add new functionalities to AI assistants without modifying core code - Implementing domain-specific tools for particular communities or industries 4. Code structure: The PR introduces several new files and modifies existing ones: a. Models: - `app/models/ai_tool.rb`: Defines the AiTool model - `app/serializers/ai_custom_tool_serializer.rb`: Serializer for AI tools b. Controllers: - `app/controllers/discourse_ai/admin/ai_tools_controller.rb`: Handles CRUD operations for AI tools c. Views and Components: - New Ember.js components for tool management in the admin interface - Updates to existing AI persona management components to support custom tools d. Core functionality: - `lib/ai_bot/tool_runner.rb`: Implements the custom tool execution system - `lib/ai_bot/tools/custom.rb`: Defines the custom tool class e. Routes and configurations: - Updates to route configurations to include new AI tool management pages f. Migrations: - `db/migrate/20240618080148_create_ai_tools.rb`: Creates the ai_tools table g. Tests: - New test files for AI tool functionality and integration The PR integrates the custom tools system with the existing AI persona framework, allowing personas to use both built-in and custom tools. It also includes safety measures such as timeouts and HTTP request limits to prevent misuse of custom tools. Overall, this PR significantly enhances the flexibility and extensibility of the Discourse AI system by allowing administrators to create and manage custom AI tools tailored to their specific needs. Co-authored-by: Martin Brennan <martin@discourse.org>
1 parent af4f871 commit b863ddc

39 files changed

+1939
-46
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import DiscourseRoute from "discourse/routes/discourse";
2+
3+
export default DiscourseRoute.extend({
4+
async model() {
5+
const record = this.store.createRecord("ai-tool");
6+
return record;
7+
},
8+
9+
setupController(controller, model) {
10+
this._super(controller, model);
11+
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
12+
13+
controller.set("allTools", toolsModel);
14+
controller.set("presets", toolsModel.resultSetMeta.presets);
15+
},
16+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import DiscourseRoute from "discourse/routes/discourse";
2+
3+
export default DiscourseRoute.extend({
4+
async model(params) {
5+
const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools");
6+
const id = parseInt(params.id, 10);
7+
return allTools.findBy("id", id);
8+
},
9+
10+
setupController(controller, model) {
11+
this._super(controller, model);
12+
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
13+
14+
controller.set("allTools", toolsModel);
15+
controller.set("presets", toolsModel.resultSetMeta.presets);
16+
},
17+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import DiscourseRoute from "discourse/routes/discourse";
2+
3+
export default class DiscourseAiToolsRoute extends DiscourseRoute {
4+
model() {
5+
return this.store.findAll("ai-tool");
6+
}
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<AiToolListEditor @tools={{this.model}} />
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<AiToolEditor
2+
@tools={{this.allTools}}
3+
@model={{this.model}}
4+
@presets={{this.presets}}
5+
/>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<AiToolEditor
2+
@tools={{this.allTools}}
3+
@model={{this.model}}
4+
@presets={{this.presets}}
5+
/>

app/controllers/discourse_ai/admin/ai_personas_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ def index
1919
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
2020
AiToolSerializer.new(tool, root: false)
2121
end
22+
AiTool
23+
.where(enabled: true)
24+
.each do |tool|
25+
tools << {
26+
id: "custom-#{tool.id}",
27+
name: I18n.t("discourse_ai.tools.custom_name", name: tool.name.capitalize),
28+
}
29+
end
2230
llms =
2331
DiscourseAi::Configuration::LlmEnumerator.values.map do |hash|
2432
{ id: hash[:value], name: hash[:name] }
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Admin
5+
class AiToolsController < ::Admin::AdminController
6+
requires_plugin ::DiscourseAi::PLUGIN_NAME
7+
8+
before_action :find_ai_tool, only: %i[show update destroy]
9+
10+
def index
11+
ai_tools = AiTool.all
12+
render_serialized({ ai_tools: ai_tools }, AiCustomToolListSerializer, root: false)
13+
end
14+
15+
def show
16+
render_serialized(@ai_tool, AiCustomToolSerializer)
17+
end
18+
19+
def create
20+
ai_tool = AiTool.new(ai_tool_params)
21+
ai_tool.created_by_id = current_user.id
22+
23+
if ai_tool.save
24+
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
25+
else
26+
render_json_error ai_tool
27+
end
28+
end
29+
30+
def update
31+
if @ai_tool.update(ai_tool_params)
32+
render_serialized(@ai_tool, AiCustomToolSerializer)
33+
else
34+
render_json_error @ai_tool
35+
end
36+
end
37+
38+
def destroy
39+
if @ai_tool.destroy
40+
head :no_content
41+
else
42+
render_json_error @ai_tool
43+
end
44+
end
45+
46+
def test
47+
if params[:id].present?
48+
ai_tool = AiTool.find(params[:id])
49+
else
50+
ai_tool = AiTool.new(ai_tool_params)
51+
end
52+
53+
parameters = params[:parameters].to_unsafe_h
54+
55+
# we need an llm so we have a tokenizer
56+
# but will do without if none is available
57+
llm = LlmModel.first&.to_llm
58+
runner = ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {})
59+
result = runner.invoke
60+
61+
if result.is_a?(Hash) && result[:error]
62+
render_json_error result[:error]
63+
else
64+
render json: { output: result }
65+
end
66+
rescue ActiveRecord::RecordNotFound => e
67+
render_json_error e.message, status: 400
68+
rescue => e
69+
render_json_error "Error executing the tool: #{e.message}", status: 400
70+
end
71+
72+
private
73+
74+
def find_ai_tool
75+
@ai_tool = AiTool.find(params[:id])
76+
end
77+
78+
def ai_tool_params
79+
params.require(:ai_tool).permit(
80+
:name,
81+
:description,
82+
:script,
83+
:summary,
84+
parameters: %i[name type description],
85+
)
86+
end
87+
end
88+
end
89+
end

app/models/ai_persona.rb

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,25 @@ def class_instance
142142
options = {}
143143
tools =
144144
self.tools.filter_map do |element|
145-
inner_name, current_options = element.is_a?(Array) ? element : [element, nil]
146-
inner_name = inner_name.gsub("Tool", "")
147-
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
145+
klass = nil
146+
147+
if element.is_a?(String) && element.start_with?("custom-")
148+
custom_tool_id = element.split("-", 2).last.to_i
149+
if AiTool.exists?(id: custom_tool_id, enabled: true)
150+
klass = DiscourseAi::AiBot::Tools::Custom.class_instance(custom_tool_id)
151+
end
152+
else
153+
inner_name, current_options = element.is_a?(Array) ? element : [element, nil]
154+
inner_name = inner_name.gsub("Tool", "")
155+
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
156+
157+
begin
158+
klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize
159+
options[klass] = current_options if current_options
160+
rescue StandardError
161+
end
148162

149-
begin
150-
klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize
151-
options[klass] = current_options if current_options
152163
klass
153-
rescue StandardError
154-
nil
155164
end
156165
end
157166

app/models/ai_tool.rb

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# frozen_string_literal: true
2+
3+
class AiTool < ActiveRecord::Base
4+
validates :name, presence: true, length: { maximum: 255 }
5+
validates :description, presence: true, length: { maximum: 1000 }
6+
validates :script, presence: true, length: { maximum: 100_000 }
7+
validates :created_by_id, presence: true
8+
belongs_to :created_by, class_name: "User"
9+
10+
def signature
11+
{ name: name, description: description, parameters: parameters.map(&:symbolize_keys) }
12+
end
13+
14+
def runner(parameters, llm:, bot_user:, context: {})
15+
DiscourseAi::AiBot::ToolRunner.new(
16+
parameters: parameters,
17+
llm: llm,
18+
bot_user: bot_user,
19+
context: context,
20+
tool: self,
21+
)
22+
end
23+
24+
after_commit :bump_persona_cache
25+
26+
def bump_persona_cache
27+
AiPersona.persona_cache.flush!
28+
end
29+
30+
def self.presets
31+
[
32+
{
33+
preset_id: "browse_web_jina",
34+
name: "browse_web",
35+
description: "Browse the web as a markdown document",
36+
parameters: [
37+
{ name: "url", type: "string", required: true, description: "The URL to browse" },
38+
],
39+
script: <<~SCRIPT,
40+
let url;
41+
function invoke(p) {
42+
url = p.url;
43+
result = http.get(`https://r.jina.ai/${url}`);
44+
// truncates to 15000 tokens
45+
return llm.truncate(result.body, 15000);
46+
}
47+
function details() {
48+
return "Read: " + url
49+
}
50+
SCRIPT
51+
},
52+
{
53+
preset_id: "exchange_rate",
54+
name: "exchange_rate",
55+
description: "Get current exchange rates for various currencies",
56+
parameters: [
57+
{
58+
name: "base_currency",
59+
type: "string",
60+
required: true,
61+
description: "The base currency code (e.g., USD, EUR)",
62+
},
63+
{
64+
name: "target_currency",
65+
type: "string",
66+
required: true,
67+
description: "The target currency code (e.g., EUR, JPY)",
68+
},
69+
{ name: "amount", type: "number", description: "Amount to convert eg: 123.45" },
70+
],
71+
script: <<~SCRIPT,
72+
// note: this script uses the open.er-api.com service, it is only updated
73+
// once every 24 hours, for more up to date rates see: https://www.exchangerate-api.com
74+
function invoke(params) {
75+
const url = `https://open.er-api.com/v6/latest/${params.base_currency}`;
76+
const result = http.get(url);
77+
if (result.status !== 200) {
78+
return { error: "Failed to fetch exchange rates" };
79+
}
80+
const data = JSON.parse(result.body);
81+
const rate = data.rates[params.target_currency];
82+
if (!rate) {
83+
return { error: "Target currency not found" };
84+
}
85+
86+
const rval = {
87+
base_currency: params.base_currency,
88+
target_currency: params.target_currency,
89+
exchange_rate: rate,
90+
last_updated: data.time_last_update_utc
91+
};
92+
93+
if (params.amount) {
94+
rval.original_amount = params.amount;
95+
rval.converted_amount = params.amount * rate;
96+
}
97+
98+
return rval;
99+
}
100+
101+
function details() {
102+
return "<a href='https://www.exchangerate-api.com'>Rates By Exchange Rate API</a>";
103+
}
104+
SCRIPT
105+
summary: "Get current exchange rates between two currencies",
106+
},
107+
{
108+
preset_id: "stock_quote",
109+
name: "stock_quote",
110+
description: "Get real-time stock quote information using AlphaVantage API",
111+
parameters: [
112+
{
113+
name: "symbol",
114+
type: "string",
115+
required: true,
116+
description: "The stock symbol (e.g., AAPL, GOOGL)",
117+
},
118+
],
119+
script: <<~SCRIPT,
120+
function invoke(params) {
121+
const apiKey = 'YOUR_ALPHAVANTAGE_API_KEY'; // Replace with your actual API key
122+
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${params.symbol}&apikey=${apiKey}`;
123+
124+
const result = http.get(url);
125+
if (result.status !== 200) {
126+
return { error: "Failed to fetch stock quote" };
127+
}
128+
129+
const data = JSON.parse(result.body);
130+
if (data['Error Message']) {
131+
return { error: data['Error Message'] };
132+
}
133+
134+
const quote = data['Global Quote'];
135+
if (!quote || Object.keys(quote).length === 0) {
136+
return { error: "No data found for the given symbol" };
137+
}
138+
139+
return {
140+
symbol: quote['01. symbol'],
141+
price: parseFloat(quote['05. price']),
142+
change: parseFloat(quote['09. change']),
143+
change_percent: quote['10. change percent'],
144+
volume: parseInt(quote['06. volume']),
145+
latest_trading_day: quote['07. latest trading day']
146+
};
147+
}
148+
149+
function details() {
150+
return "<a href='https://www.alphavantage.co'>Stock data provided by AlphaVantage</a>";
151+
}
152+
SCRIPT
153+
summary: "Get real-time stock quotes using AlphaVantage API",
154+
},
155+
{ preset_id: "empty_tool", script: <<~SCRIPT },
156+
function invoke(params) {
157+
// logic here
158+
return params;
159+
}
160+
function details() {
161+
return "Details about this tool";
162+
}
163+
SCRIPT
164+
].map do |preset|
165+
preset[:preset_name] = I18n.t("discourse_ai.tools.presets.#{preset[:preset_id]}.name")
166+
preset
167+
end
168+
end
169+
end
170+
171+
# == Schema Information
172+
#
173+
# Table name: ai_tools
174+
#
175+
# id :bigint not null, primary key
176+
# name :string not null
177+
# description :text not null
178+
# parameters :jsonb not null
179+
# script :text not null
180+
# created_by_id :integer not null
181+
# created_at :datetime not null
182+
# updated_at :datetime not null
183+
#

app/models/llm_model.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def self.provider_params
4141
}
4242
end
4343

44+
def to_llm
45+
DiscourseAi::Completions::Llm.proxy_from_obj(self)
46+
end
47+
4448
def toggle_companion_user
4549
return if name == "fake" && Rails.env.production?
4650

0 commit comments

Comments
 (0)