diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js new file mode 100644 index 00000000..5f669195 --- /dev/null +++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-new.js @@ -0,0 +1,16 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default DiscourseRoute.extend({ + async model() { + const record = this.store.createRecord("ai-tool"); + return record; + }, + + setupController(controller, model) { + this._super(controller, model); + const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools"); + + controller.set("allTools", toolsModel); + controller.set("presets", toolsModel.resultSetMeta.presets); + }, +}); diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-show.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-show.js new file mode 100644 index 00000000..d111e02e --- /dev/null +++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools-show.js @@ -0,0 +1,17 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default DiscourseRoute.extend({ + async model(params) { + const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools"); + const id = parseInt(params.id, 10); + return allTools.findBy("id", id); + }, + + setupController(controller, model) { + this._super(controller, model); + const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools"); + + controller.set("allTools", toolsModel); + controller.set("presets", toolsModel.resultSetMeta.presets); + }, +}); diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js new file mode 100644 index 00000000..b24e96ab --- /dev/null +++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-tools.js @@ -0,0 +1,7 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class DiscourseAiToolsRoute extends DiscourseRoute { + model() { + return this.store.findAll("ai-tool"); + } +} diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs new file mode 100644 index 00000000..1b0dbe81 --- /dev/null +++ b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/index.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs new file mode 100644 index 00000000..88f8a934 --- /dev/null +++ b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/new.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/show.hbs b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/show.hbs new file mode 100644 index 00000000..88f8a934 --- /dev/null +++ b/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-ai-tools/show.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index 3dc17a15..fbd3dce7 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -19,6 +19,14 @@ module DiscourseAi DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool| AiToolSerializer.new(tool, root: false) end + AiTool + .where(enabled: true) + .each do |tool| + tools << { + id: "custom-#{tool.id}", + name: I18n.t("discourse_ai.tools.custom_name", name: tool.name.capitalize), + } + end llms = DiscourseAi::Configuration::LlmEnumerator.values.map do |hash| { id: hash[:value], name: hash[:name] } diff --git a/app/controllers/discourse_ai/admin/ai_tools_controller.rb b/app/controllers/discourse_ai/admin/ai_tools_controller.rb new file mode 100644 index 00000000..0e61d923 --- /dev/null +++ b/app/controllers/discourse_ai/admin/ai_tools_controller.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module DiscourseAi + module Admin + class AiToolsController < ::Admin::AdminController + requires_plugin ::DiscourseAi::PLUGIN_NAME + + before_action :find_ai_tool, only: %i[show update destroy] + + def index + ai_tools = AiTool.all + render_serialized({ ai_tools: ai_tools }, AiCustomToolListSerializer, root: false) + end + + def show + render_serialized(@ai_tool, AiCustomToolSerializer) + end + + def create + ai_tool = AiTool.new(ai_tool_params) + ai_tool.created_by_id = current_user.id + + if ai_tool.save + render_serialized(ai_tool, AiCustomToolSerializer, status: :created) + else + render_json_error ai_tool + end + end + + def update + if @ai_tool.update(ai_tool_params) + render_serialized(@ai_tool, AiCustomToolSerializer) + else + render_json_error @ai_tool + end + end + + def destroy + if @ai_tool.destroy + head :no_content + else + render_json_error @ai_tool + end + end + + def test + if params[:id].present? + ai_tool = AiTool.find(params[:id]) + else + ai_tool = AiTool.new(ai_tool_params) + end + + parameters = params[:parameters].to_unsafe_h + + # we need an llm so we have a tokenizer + # but will do without if none is available + llm = LlmModel.first&.to_llm + runner = ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {}) + result = runner.invoke + + if result.is_a?(Hash) && result[:error] + render_json_error result[:error] + else + render json: { output: result } + end + rescue ActiveRecord::RecordNotFound => e + render_json_error e.message, status: 400 + rescue => e + render_json_error "Error executing the tool: #{e.message}", status: 400 + end + + private + + def find_ai_tool + @ai_tool = AiTool.find(params[:id]) + end + + def ai_tool_params + params.require(:ai_tool).permit( + :name, + :description, + :script, + :summary, + parameters: %i[name type description], + ) + end + end + end +end diff --git a/app/models/ai_persona.rb b/app/models/ai_persona.rb index a18499be..6a6e4c3f 100644 --- a/app/models/ai_persona.rb +++ b/app/models/ai_persona.rb @@ -142,16 +142,25 @@ class AiPersona < ActiveRecord::Base options = {} tools = self.tools.filter_map do |element| - inner_name, current_options = element.is_a?(Array) ? element : [element, nil] - inner_name = inner_name.gsub("Tool", "") - inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name) + klass = nil + + if element.is_a?(String) && element.start_with?("custom-") + custom_tool_id = element.split("-", 2).last.to_i + if AiTool.exists?(id: custom_tool_id, enabled: true) + klass = DiscourseAi::AiBot::Tools::Custom.class_instance(custom_tool_id) + end + else + inner_name, current_options = element.is_a?(Array) ? element : [element, nil] + inner_name = inner_name.gsub("Tool", "") + inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name) + + begin + klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize + options[klass] = current_options if current_options + rescue StandardError + end - begin - klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize - options[klass] = current_options if current_options klass - rescue StandardError - nil end end diff --git a/app/models/ai_tool.rb b/app/models/ai_tool.rb new file mode 100644 index 00000000..79287573 --- /dev/null +++ b/app/models/ai_tool.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +class AiTool < ActiveRecord::Base + validates :name, presence: true, length: { maximum: 255 } + validates :description, presence: true, length: { maximum: 1000 } + validates :script, presence: true, length: { maximum: 100_000 } + validates :created_by_id, presence: true + belongs_to :created_by, class_name: "User" + + def signature + { name: name, description: description, parameters: parameters.map(&:symbolize_keys) } + end + + def runner(parameters, llm:, bot_user:, context: {}) + DiscourseAi::AiBot::ToolRunner.new( + 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 + + def self.presets + [ + { + preset_id: "browse_web_jina", + name: "browse_web", + description: "Browse the web as a markdown document", + parameters: [ + { name: "url", type: "string", required: true, description: "The URL to browse" }, + ], + script: <<~SCRIPT, + 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", + name: "exchange_rate", + 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, + // 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 "Rates By Exchange Rate API"; + } + SCRIPT + summary: "Get current exchange rates between two currencies", + }, + { + preset_id: "stock_quote", + name: "stock_quote", + 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, + 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 "Stock data provided by AlphaVantage"; + } + SCRIPT + summary: "Get real-time stock quotes using AlphaVantage API", + }, + { preset_id: "empty_tool", script: <<~SCRIPT }, + 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 +# +# id :bigint not null, primary key +# name :string not null +# description :text not null +# parameters :jsonb not null +# script :text not null +# created_by_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/app/models/llm_model.rb b/app/models/llm_model.rb index 2eb45913..a818d6b2 100644 --- a/app/models/llm_model.rb +++ b/app/models/llm_model.rb @@ -41,6 +41,10 @@ class LlmModel < ActiveRecord::Base } end + def to_llm + DiscourseAi::Completions::Llm.proxy_from_obj(self) + end + def toggle_companion_user return if name == "fake" && Rails.env.production? diff --git a/app/serializers/ai_custom_tool_list_serializer.rb b/app/serializers/ai_custom_tool_list_serializer.rb new file mode 100644 index 00000000..f0e3f1cc --- /dev/null +++ b/app/serializers/ai_custom_tool_list_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AiCustomToolListSerializer < ApplicationSerializer + attributes :meta + + has_many :ai_tools, serializer: AiCustomToolSerializer, embed: :objects + + def meta + { presets: AiTool.presets } + end + + def ai_tools + object[:ai_tools] + end +end diff --git a/app/serializers/ai_custom_tool_serializer.rb b/app/serializers/ai_custom_tool_serializer.rb new file mode 100644 index 00000000..c95482ad --- /dev/null +++ b/app/serializers/ai_custom_tool_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AiCustomToolSerializer < ApplicationSerializer + attributes :id, + :name, + :description, + :summary, + :parameters, + :script, + :created_by_id, + :created_at, + :updated_at + + self.root = "ai_tool" +end diff --git a/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js index 97ed05e5..f71f313a 100644 --- a/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js +++ b/assets/javascripts/discourse/admin-discourse-ai-plugin-route-map.js @@ -13,5 +13,10 @@ export default { this.route("new"); this.route("show", { path: "/:id" }); }); + + this.route("discourse-ai-tools", { path: "ai-tools" }, function () { + this.route("new"); + this.route("show", { path: "/:id" }); + }); }, }; diff --git a/assets/javascripts/discourse/admin/adapters/ai-tool.js b/assets/javascripts/discourse/admin/adapters/ai-tool.js new file mode 100644 index 00000000..15cfeb84 --- /dev/null +++ b/assets/javascripts/discourse/admin/adapters/ai-tool.js @@ -0,0 +1,21 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default class Adapter extends RestAdapter { + jsonMode = true; + + basePath() { + return "/admin/plugins/discourse-ai/"; + } + + pathFor(store, type, findArgs) { + // removes underscores which are implemented in base + let path = + this.basePath(store, type, findArgs) + + store.pluralize(this.apiNameFor(type)); + return this.appendQueryParams(path, findArgs); + } + + apiNameFor() { + return "ai-tool"; + } +} diff --git a/assets/javascripts/discourse/admin/models/ai-tool.js b/assets/javascripts/discourse/admin/models/ai-tool.js new file mode 100644 index 00000000..d1f32ce5 --- /dev/null +++ b/assets/javascripts/discourse/admin/models/ai-tool.js @@ -0,0 +1,27 @@ +import RestModel from "discourse/models/rest"; + +const CREATE_ATTRIBUTES = [ + "id", + "name", + "description", + "parameters", + "script", + "summary", + "enabled", +]; + +export default class AiTool extends RestModel { + createProperties() { + return this.getProperties(CREATE_ATTRIBUTES); + } + + updateProperties() { + return this.getProperties(CREATE_ATTRIBUTES); + } + + workingCopy() { + let attrs = this.getProperties(CREATE_ATTRIBUTES); + attrs.parameters = attrs.parameters || []; + return AiTool.create(attrs); + } +} diff --git a/assets/javascripts/discourse/components/ai-tool-editor.gjs b/assets/javascripts/discourse/components/ai-tool-editor.gjs new file mode 100644 index 00000000..1882f440 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-tool-editor.gjs @@ -0,0 +1,223 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { inject as service } from "@ember/service"; +import BackButton from "discourse/components/back-button"; +import DButton from "discourse/components/d-button"; +import Textarea from "discourse/components/d-textarea"; +import DTooltip from "discourse/components/d-tooltip"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "discourse-i18n"; +import AceEditor from "admin/components/ace-editor"; +import ComboBox from "select-kit/components/combo-box"; +import AiToolParameterEditor from "./ai-tool-parameter-editor"; +import AiToolTestModal from "./modal/ai-tool-test-modal"; + +export default class AiToolEditor extends Component { + @service router; + @service store; + @service dialog; + @service modal; + @service toasts; + + @tracked isSaving = false; + @tracked editingModel = null; + @tracked showDelete = false; + + @tracked selectedPreset = null; + + aceEditorMode = "javascript"; + aceEditorTheme = "chrome"; + + @action + updateModel() { + this.editingModel = this.args.model.workingCopy(); + this.showDelete = !this.args.model.isNew; + } + + get presets() { + return this.args.presets.map((preset) => { + return { + name: preset.preset_name, + id: preset.preset_id, + }; + }); + } + + get showPresets() { + return !this.selectedPreset && this.args.model.isNew; + } + + @action + configurePreset() { + this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId); + this.editingModel = this.args.model.workingCopy(); + this.editingModel.setProperties(this.selectedPreset); + this.showDelete = false; + } + + @action + async save() { + this.isSaving = true; + + try { + await this.args.model.save( + this.editingModel.getProperties( + "name", + "description", + "parameters", + "script", + "summary" + ) + ); + + this.toasts.success({ + data: { message: I18n.t("discourse_ai.tools.saved") }, + duration: 2000, + }); + if (!this.args.tools.any((tool) => tool.id === this.args.model.id)) { + this.args.tools.pushObject(this.args.model); + } + + this.router.transitionTo( + "adminPlugins.show.discourse-ai-tools.show", + this.args.model + ); + } catch (e) { + popupAjaxError(e); + } finally { + this.isSaving = false; + } + } + + @action + delete() { + return this.dialog.confirm({ + message: I18n.t("discourse_ai.tools.confirm_delete"), + didConfirm: () => { + return this.args.model.destroyRecord().then(() => { + this.args.tools.removeObject(this.args.model); + this.router.transitionTo( + "adminPlugins.show.discourse-ai-tools.index" + ); + }); + }, + }); + } + + @action + updateScript(script) { + this.editingModel.script = script; + } + + @action + openTestModal() { + this.modal.show(AiToolTestModal, { + model: { + tool: this.editingModel, + }, + }); + } + +