2024-06-27 17:27:40 +10:00
# frozen_string_literal: true
class AiTool < ActiveRecord :: Base
2025-02-07 14:34:47 +11:00
validates :name , presence : true , length : { maximum : 100 } , uniqueness : true
validates :tool_name , presence : true , length : { maximum : 100 }
2024-06-27 17:27:40 +10:00
validates :description , presence : true , length : { maximum : 1000 }
2024-06-28 00:59:51 +02:00
validates :summary , presence : true , length : { maximum : 255 }
2024-06-27 17:27:40 +10:00
validates :script , presence : true , length : { maximum : 100_000 }
validates :created_by_id , presence : true
belongs_to :created_by , class_name : " User "
2025-02-14 12:15:07 +11:00
belongs_to :rag_llm_model , class_name : " LlmModel "
2024-09-30 16:27:50 +09:00
has_many :rag_document_fragments , dependent : :destroy , as : :target
has_many :upload_references , as : :target , dependent : :destroy
has_many :uploads , through : :upload_references
before_update :regenerate_rag_fragments
2024-06-27 17:27:40 +10:00
2025-02-07 14:34:47 +11:00
ALPHANUMERIC_PATTERN = / \ A[a-zA-Z0-9_]+ \ z /
validates :tool_name ,
format : {
with : ALPHANUMERIC_PATTERN ,
message : I18n . t ( " discourse_ai.tools.name.characters " ) ,
}
2024-06-27 17:27:40 +10:00
def signature
2025-02-07 14:34:47 +11:00
{
name : function_call_name ,
description : description ,
parameters : parameters . map ( & :symbolize_keys ) ,
}
end
# Backwards compatibility: if tool_name is not set (existing custom tools), use name
def function_call_name
tool_name . presence || name
2024-06-27 17:27:40 +10:00
end
2025-04-01 02:39:07 +11:00
def runner ( parameters , llm : , bot_user : , context : nil )
2025-03-31 14:42:33 -03:00
DiscourseAi :: Personas :: ToolRunner . new (
2024-06-27 17:27:40 +10:00
parameters : parameters ,
llm : llm ,
bot_user : bot_user ,
context : context ,
tool : self ,
)
end
after_commit :bump_persona_cache
def bump_persona_cache
AiPersona . persona_cache . flush!
end
2024-09-30 16:27:50 +09:00
def regenerate_rag_fragments
if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed?
RagDocumentFragment . where ( target : self ) . delete_all
end
end
def self . preamble
<< ~ JS
2025-04-01 02:39:07 +11:00
/ **
* Tool API Quick Reference
*
* Entry Functions
*
* invoke(parameters): Main function. Receives parameters defined in the tool's signature (Object).
* Must return a JSON-serializable value (e.g., string, number, object, array).
* Example:
* function invoke(parameters) { return { result: "Data processed", input: parameters.query }; }
*
* details(): Optional function. Returns a string (can include basic HTML) describing
* the tool's action after invocation, often using data from the invocation.
* This is displayed in the chat interface.
* Example:
* let lastUrl;
* function invoke(parameters) {
* lastUrl = parameters.url;
* / / ... perform action ...
* return { success : true , content : " ... " } ;
* }
* function details ( ) {
* return ` Browsed: <a href="${lastUrl}">${lastUrl}</a> ` ;
* }
*
* Provided Objects & Functions
*
* 1 . http
* Performs HTTP requests . Max 20 requests per execution .
*
* http . get ( url , options? ) : Performs GET request .
* Parameters :
* url ( string ) : The request URL .
* options ( Object , optional ) :
* headers ( Object ) : Request headers ( e . g . , { " Authorization " : " Bearer key " } ) .
* Returns : { status : number , body : string }
*
* http . post ( url , options? ) : Performs POST request .
* Parameters :
* url ( string ) : The request URL .
* options ( Object , optional ) :
* headers ( Object ) : Request headers .
* body ( string | Object ) : Request body . If an object , it 's stringified as JSON.
* Returns: { status: number, body: string }
*
* http.put(url, options?): Performs PUT request (similar to POST).
* http.patch(url, options?): Performs PATCH request (similar to POST).
* http.delete(url, options?): Performs DELETE request (similar to GET/POST).
*
* 2. llm
* Interacts with the Language Model.
*
* llm.truncate(text, length): Truncates text to a specified token length based on the configured LLM' s tokenizer .
* Parameters :
* text ( string ) : Text to truncate .
* length ( number ) : Maximum number of tokens .
* Returns : string ( truncated text )
*
* llm . generate ( prompt ) : Generates text using the configured LLM associated with the tool runner .
* Parameters :
* prompt ( string | Object ) : The prompt . Can be a simple string or an object
* like { messages : [ { type : " system " , content : " ... " } , { type : " user " , content : " ... " } ] } .
* Returns : string ( generated text )
*
* 3 . index
* Searches attached RAG ( Retrieval - Augmented Generation ) documents linked to this tool .
*
* index . search ( query , options? ) : Searches indexed document fragments .
* Parameters :
* query ( string ) : The search query used for semantic search .
* options ( Object , optional ) :
* filenames ( Array < string > ) : Filter search to fragments from specific uploaded filenames .
* limit ( number ) : Maximum number of fragments to return ( default : 10 , max : 200 ) .
* Returns : Array < { fragment : string , metadata : string | null } > - Ordered by relevance .
*
* 4 . upload
* Handles file uploads within Discourse .
*
* upload . create ( filename , base_64_content ) : Uploads a file created by the tool , making it available in Discourse .
* Parameters :
* filename ( string ) : The desired name for the file ( basename is used for security ) .
* base_64_content ( string ) : Base64 encoded content of the file .
* Returns : { id : number , url : string , short_url : string } - Details of the created upload record .
*
* 5 . chain
* Controls the execution flow .
*
* chain . setCustomRaw ( raw ) : Sets the final raw content of the bot 's post and immediately
* stops the tool execution chain. Useful for tools that directly
* generate the full response content (e.g., image generation tools attaching the image markdown).
* Parameters:
* raw (string): The raw Markdown content for the post.
* Returns: void
*
* 6. discourse
* Interacts with Discourse specific features. Access is generally performed as the SystemUser.
*
* discourse.search(params): Performs a Discourse search.
* Parameters:
* params (Object): Search parameters (e.g., { search_query: "keyword", with_private: true, max_results: 10 }).
* `with_private: true` searches across all posts visible to the SystemUser. `result_style: ' detailed '` is used by default.
* Returns: Object (Discourse search results structure, includes posts, topics, users etc.)
*
* discourse.getPost(post_id): Retrieves details for a specific post.
* Parameters:
* post_id (number): The ID of the post.
* Returns: Object (Post details including `raw`, nested `topic` object with ListableTopicSerializer structure) or null if not found/accessible.
*
* discourse.getTopic(topic_id): Retrieves details for a specific topic.
* Parameters:
* topic_id (number): The ID of the topic.
* Returns: Object (Topic details using ListableTopicSerializer structure) or null if not found/accessible.
*
* discourse.getUser(user_id_or_username): Retrieves details for a specific user.
* Parameters:
* user_id_or_username (number | string): The ID or username of the user.
* Returns: Object (User details using UserSerializer structure) or null if not found.
*
* discourse.getPersona(name): Gets an object representing another AI Persona configured on the site.
* Parameters:
* name (string): The name of the target persona.
* Returns: Object { respondTo: function(params) } or null if persona not found.
* respondTo(params): Instructs the target persona to generate a response within the current context (e.g., replying to the same post or chat message).
* Parameters:
* params (Object, optional): { instructions: string, whisper: boolean }
* Returns: { success: boolean, post_id?: number, post_number?: number, message_id?: number } or { error: string }
*
* discourse.createChatMessage(params): Creates a new message in a Discourse Chat channel.
* Parameters:
* params (Object): { channel_name: string, username: string, message: string }
* `channel_name` can be the channel name or slug.
* `username` specifies the user who should appear as the sender. The user must exist.
* The sending user must have permission to post in the channel.
* Returns: { success: boolean, message_id?: number, message?: string, created_at?: string } or { error: string }
*
* 7. context
* An object containing information about the environment where the tool is being run.
* Available properties depend on the invocation context, but may include:
* post_id (number): ID of the post triggering the tool (if in a Post context).
* topic_id (number): ID of the topic (if in a Post context).
* private_message (boolean): Whether the context is a private message (in Post context).
* message_id (number): ID of the chat message triggering the tool (if in Chat context).
* channel_id (number): ID of the chat channel (if in Chat context).
* user (Object): Details of the user invoking the tool/persona (structure may vary, often null or SystemUser details unless explicitly passed).
* participants (string): Comma-separated list of usernames in a PM (if applicable).
* // ... other potential context-specific properties added by the calling environment.
*
* Constraints
*
* Execution Time: ≤ 2000ms (default timeout in milliseconds) - This timer *pauses* during external HTTP requests or LLM calls initiated via `http.*` or `llm.generate`, but applies to the script' s own processing time .
* Memory : ≤ 10 MB ( V8 heap limit )
* Stack Depth : ≤ 20 ( Marshal stack depth limit for Ruby interop )
* HTTP Requests : ≤ 20 per execution
* Exceeding limits will result in errors or termination ( e . g . , timeout error , out - of - memory error , TooManyRequestsError ) .
*
* Security
*
* Sandboxed Environment : The script runs in a restricted V8 JavaScript environment ( via MiniRacer ) .
* No direct access to browser or environment , browser globals ( like ` window ` or ` document ` ) , or the host system 's file system.
* Network requests are proxied through the Discourse backend, not made directly from the sandbox.
*/
2024-09-30 16:27:50 +09:00
JS
end
2024-06-27 17:27:40 +10:00
def self.presets
[
{
preset_id: "browse_web_jina",
2025-02-07 14:34:47 +11:00
name: "Browse Web",
tool_name: "browse_web",
2024-06-27 17:27:40 +10:00
description: "Browse the web as a markdown document",
parameters: [
{ name: "url", type: "string", required: true, description: "The URL to browse" },
],
script: <<~SCRIPT,
2024-09-30 16:27:50 +09:00
#{preamble}
2024-06-27 17:27:40 +10:00
let url;
function invoke(p) {
url = p.url;
result = http.get(`https://r.jina.ai/${url}`);
// truncates to 15000 tokens
return llm.truncate(result.body, 15000);
}
function details() {
return "Read: " + url
}
SCRIPT
},
{
preset_id: "exchange_rate",
2025-02-07 14:34:47 +11:00
name: "Exchange Rate",
tool_name: "exchange_rate",
2024-06-27 17:27:40 +10:00
description: "Get current exchange rates for various currencies",
parameters: [
{
name: "base_currency",
type: "string",
required: true,
description: "The base currency code (e.g., USD, EUR)",
},
{
name: "target_currency",
type: "string",
required: true,
description: "The target currency code (e.g., EUR, JPY)",
},
{ name: "amount", type: "number", description: "Amount to convert eg: 123.45" },
],
script: <<~SCRIPT,
2024-09-30 16:27:50 +09:00
#{preamble}
2024-06-27 17:27:40 +10:00
// note: this script uses the open.er-api.com service, it is only updated
// once every 24 hours, for more up to date rates see: https://www.exchangerate-api.com
function invoke(params) {
const url = `https://open.er-api.com/v6/latest/${params.base_currency}`;
const result = http.get(url);
if (result.status !== 200) {
return { error: "Failed to fetch exchange rates" };
}
const data = JSON.parse(result.body);
const rate = data.rates[params.target_currency];
if (!rate) {
return { error: "Target currency not found" };
}
const rval = {
base_currency: params.base_currency,
target_currency: params.target_currency,
exchange_rate: rate,
last_updated: data.time_last_update_utc
};
if (params.amount) {
rval.original_amount = params.amount;
rval.converted_amount = params.amount * rate;
}
return rval;
}
function details() {
return "<a href=' https : / / www . exchangerate - api . com '>Rates By Exchange Rate API</a>";
}
SCRIPT
summary: "Get current exchange rates between two currencies",
},
{
preset_id: "stock_quote",
2025-02-07 14:34:47 +11:00
name: "Stock Quote (AlphaVantage)",
tool_name: "stock_quote",
2024-06-27 17:27:40 +10:00
description: "Get real-time stock quote information using AlphaVantage API",
parameters: [
{
name: "symbol",
type: "string",
required: true,
description: "The stock symbol (e.g., AAPL, GOOGL)",
},
],
script: <<~SCRIPT,
2024-09-30 16:27:50 +09:00
#{preamble}
2024-06-27 17:27:40 +10:00
function invoke(params) {
const apiKey = ' YOUR_ALPHAVANTAGE_API_KEY '; // Replace with your actual API key
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${params.symbol}&apikey=${apiKey}`;
const result = http.get(url);
if (result.status !== 200) {
return { error: "Failed to fetch stock quote" };
}
const data = JSON.parse(result.body);
if (data[' Error Message ']) {
return { error: data[' Error Message '] };
}
const quote = data[' Global Quote '];
if (!quote || Object.keys(quote).length === 0) {
return { error: "No data found for the given symbol" };
}
return {
symbol: quote[' 01 . symbol '],
price: parseFloat(quote[' 05 . price ']),
change: parseFloat(quote[' 09 . change ']),
change_percent: quote[' 10 . change percent '],
volume: parseInt(quote[' 06 . volume ']),
latest_trading_day: quote[' 07 . latest trading day ']
};
}
function details() {
return "<a href=' https : / / www . alphavantage . co ' > Stock data provided by AlphaVantage < / a>";
}
SCRIPT
summary: "Get real-time stock quotes using AlphaVantage API",
},
2024-10-09 08:17:45 +11:00
{
preset_id: "image_generation",
2025-02-07 14:34:47 +11:00
name: "Image Generation (Flux)",
tool_name: "image_generation",
2024-10-09 08:17:45 +11:00
description:
"Generate images using the FLUX model from Black Forest Labs using together.ai",
parameters: [
{
name: "prompt",
type: "string",
required: true,
description: "The text prompt for image generation",
},
{
name: "seed",
type: "number",
required: false,
description: "Optional seed for random number generation",
},
],
script: <<~SCRIPT,
#{ preamble }
const apiKey = "YOUR_KEY";
const model = "black-forest-labs / FLUX . 1 . 1 - pro " ;
function invoke(params) {
let seed = parseInt(params.seed);
if (!(seed > 0)) {
seed = Math.floor(Math.random() * 1000000) + 1;
}
const prompt = params.prompt;
const body = {
model: model,
prompt: prompt,
width: 1024,
height: 768,
steps: 10,
n: 1,
seed: seed,
response_format: " b64_json " ,
};
const result = http.post( " https : / / api . together . xyz / v1 / images / generations " , {
headers: {
" Authorization " : `Bearer ${apiKey}`,
" Content - Type " : " application / json " ,
},
body: JSON.stringify(body),
});
const base64Image = JSON.parse(result.body).data[0].b64_json;
const image = upload.create( " generated_image . png " , base64Image);
const raw = ` \n  \n `;
chain.setCustomRaw(raw);
return { result: " Image generated successfully " , seed: seed };
}
function details() {
return " Generates images based on a text prompt using the FLUX model . " ;
}
SCRIPT
summary: " Generate image " ,
},
2024-06-27 17:27:40 +10:00
{ preset_id: " empty_tool " , script: <<~SCRIPT },
2024-09-30 16:27:50 +09:00
#{ preamble }
2024-06-27 17:27:40 +10:00
function invoke(params) {
// logic here
return params;
}
function details() {
return " Details about this tool " ;
}
SCRIPT
].map do |preset|
preset[:preset_name] = I18n.t( " discourse_ai . tools . presets . #{preset[:preset_id]}.name")
preset
end
end
end
# == Schema Information
#
# Table name: ai_tools
#
2024-09-30 16:27:50 +09:00
# id :bigint not null, primary key
# name :string not null
# description :string not null
# summary :string not null
# parameters :jsonb not null
# script :text not null
# created_by_id :integer not null
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# rag_chunk_tokens :integer default(374), not null
# rag_chunk_overlap_tokens :integer default(10), not null
2025-02-07 14:34:47 +11:00
# tool_name :string(100) default(""), not null
2025-02-14 12:15:07 +11:00
# rag_llm_model_id :bigint