mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 01:05:17 +08:00
The workflow AI agent node's `agent_options` looked up each agent's configured LLM with a separate `LlmModel.find_by` inside a `.map`, issuing one query per enabled agent (plus one for the site default). Collect the distinct LLM ids up front and resolve them with a single `LlmModel.where(id: ids).index_by(&:id)`. The now-unused `default_llm_model`/`find_llm_model` helpers are removed.
275 lines
9.3 KiB
Ruby
Vendored
275 lines
9.3 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
if defined?(DiscourseWorkflows)
|
|
module DiscourseWorkflows
|
|
module Nodes
|
|
module AiAgent
|
|
class V1 < DiscourseWorkflows::NodeType
|
|
description(
|
|
name: "action:ai_agent",
|
|
version: "1.0",
|
|
defaults: {
|
|
icon: "robot",
|
|
color: "pink",
|
|
},
|
|
group: "ai",
|
|
available: -> { SiteSetting.discourse_ai_enabled },
|
|
unavailable_reason_key: "discourse_workflows.node_unavailable.requires_ai",
|
|
i18n_prefix: "discourse_ai.discourse_workflows",
|
|
capabilities: {
|
|
run_scope: "per_item",
|
|
},
|
|
properties: {
|
|
agent_id: {
|
|
type: :integer,
|
|
required: true,
|
|
type_options: {
|
|
load_options_method: "agents",
|
|
},
|
|
no_data_expression: true,
|
|
ui: {
|
|
control: :combo_box,
|
|
},
|
|
control_options: {
|
|
action_icon: "robot",
|
|
action_label: "discourse_ai.ai_agent.manage_agents",
|
|
action_route: "adminPlugins.show.discourse-ai-agents",
|
|
action_route_models: ["discourse-ai"],
|
|
filterable: true,
|
|
value_property: :id,
|
|
name_property: :name,
|
|
resets: %w[llm_model_id],
|
|
set_from_option: {
|
|
agent_name: "name",
|
|
agent_force_default_llm: "force_default_llm",
|
|
agent_resolved_llm_name: "resolved_llm_name",
|
|
},
|
|
},
|
|
},
|
|
agent_name: {
|
|
type: :string,
|
|
ui: {
|
|
hidden: true,
|
|
},
|
|
},
|
|
agent_force_default_llm: {
|
|
type: :boolean,
|
|
default: false,
|
|
ui: {
|
|
hidden: true,
|
|
},
|
|
},
|
|
agent_resolved_llm_name: {
|
|
type: :string,
|
|
ui: {
|
|
hidden: true,
|
|
},
|
|
},
|
|
llm_model_id: {
|
|
type: :integer,
|
|
required: false,
|
|
type_options: {
|
|
load_options_method: "llm_models",
|
|
},
|
|
no_data_expression: true,
|
|
ui: {
|
|
control: :combo_box,
|
|
},
|
|
control_options: {
|
|
filterable: true,
|
|
value_property: :id,
|
|
name_property: :name,
|
|
none: "discourse_ai.discourse_workflows.ai_agent.llm_model_default",
|
|
none_label_field: "agent_resolved_llm_name",
|
|
none_label_i18n_key:
|
|
"discourse_ai.discourse_workflows.ai_agent.llm_model_default_with_name",
|
|
},
|
|
display_options: {
|
|
hide: {
|
|
agent_force_default_llm: [true],
|
|
},
|
|
},
|
|
},
|
|
forced_llm_notice: {
|
|
type: :notice,
|
|
display_options: {
|
|
show: {
|
|
agent_force_default_llm: [true],
|
|
},
|
|
},
|
|
},
|
|
prompt: {
|
|
type: :string,
|
|
ui: {
|
|
control: :textarea,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
def self.group_definition
|
|
{ icon: "robot", label_key: "discourse_workflows.add_node.categories.ai", order: 40 }
|
|
end
|
|
|
|
def self.load_options_context(context)
|
|
case context.method_name
|
|
when "agents"
|
|
agent_options.select { |agent| context.matches_filter?(agent[:name]) }
|
|
when "llm_models"
|
|
llm_model_options(context)
|
|
end
|
|
end
|
|
|
|
def self.agent_options
|
|
agents =
|
|
::AiAgent
|
|
.where(enabled: true)
|
|
.order(:name)
|
|
.pluck(:id, :name, :default_llm_id, :force_default_llm)
|
|
|
|
site_default_llm_id = SiteSetting.ai_default_llm_model.presence&.to_i
|
|
llm_model_ids = agents.map { |_id, _name, default_llm_id, _force| default_llm_id }
|
|
llm_model_ids << site_default_llm_id
|
|
llm_models_by_id = ::LlmModel.where(id: llm_model_ids.compact.uniq).index_by(&:id)
|
|
|
|
default_llm = llm_models_by_id[site_default_llm_id]
|
|
|
|
agents.map do |id, name, default_llm_id, force_default_llm|
|
|
configured_llm = llm_models_by_id[default_llm_id]
|
|
resolved_llm = force_default_llm ? configured_llm : configured_llm || default_llm
|
|
{
|
|
id: id,
|
|
name: name,
|
|
default_llm_id: default_llm_id,
|
|
force_default_llm: force_default_llm,
|
|
resolved_llm_id: resolved_llm&.id,
|
|
resolved_llm_name: resolved_llm&.display_name,
|
|
}
|
|
end
|
|
end
|
|
|
|
def self.llm_model_options(context)
|
|
::LlmModel
|
|
.order(:display_name)
|
|
.pluck(:id, :display_name)
|
|
.filter_map do |id, display_name|
|
|
next if display_name.blank?
|
|
|
|
{ id: id, name: display_name }
|
|
end
|
|
.select { |llm_model| context.matches_filter?(llm_model[:name]) }
|
|
end
|
|
|
|
def execute(exec_ctx)
|
|
items =
|
|
exec_ctx.input_items.map.with_index do |item, item_index|
|
|
config = {
|
|
"agent_id" => exec_ctx.get_node_parameter("agent_id", item_index),
|
|
"llm_model_id" => exec_ctx.get_node_parameter("llm_model_id", item_index),
|
|
"prompt" => exec_ctx.get_node_parameter("prompt", item_index),
|
|
}
|
|
result = run_agent(config, exec_ctx.log)
|
|
|
|
wrap({ "result" => result }, paired_item: exec_ctx.paired_item_for(item))
|
|
end
|
|
|
|
[items]
|
|
end
|
|
|
|
private
|
|
|
|
def resolve_llm_model(agent_record, requested_llm_model_id)
|
|
if agent_record.force_default_llm?
|
|
llm_model =
|
|
::LlmModel.find_by(id: agent_record.default_llm_id) if agent_record.default_llm_id
|
|
return llm_model if llm_model.present?
|
|
|
|
raise_node_error!(
|
|
I18n.t(
|
|
"discourse_ai.discourse_workflows.ai_agent.errors.locked_default_llm_missing",
|
|
agent: agent_record.name,
|
|
),
|
|
)
|
|
end
|
|
|
|
if requested_llm_model_id.present?
|
|
llm_model = ::LlmModel.find_by(id: requested_llm_model_id)
|
|
return llm_model if llm_model.present?
|
|
|
|
raise_node_error!(
|
|
I18n.t(
|
|
"discourse_ai.discourse_workflows.ai_agent.errors.llm_not_found",
|
|
llm_model_id: requested_llm_model_id,
|
|
),
|
|
)
|
|
end
|
|
|
|
[agent_record.default_llm_id, SiteSetting.ai_default_llm_model].each do |llm_model_id|
|
|
llm_model = ::LlmModel.find_by(id: llm_model_id) if llm_model_id.present?
|
|
return llm_model if llm_model.present?
|
|
end
|
|
|
|
raise_node_error!(
|
|
I18n.t(
|
|
"discourse_ai.discourse_workflows.ai_agent.errors.no_llm_configured",
|
|
agent: agent_record.name,
|
|
),
|
|
)
|
|
end
|
|
|
|
def run_agent(config, log)
|
|
agent_id = config["agent_id"]
|
|
prompt = config["prompt"].to_s
|
|
|
|
agent_record = ::AiAgent.find_by(id: agent_id)
|
|
raise_node_error!("AI Agent with id #{agent_id} not found") if agent_record.nil?
|
|
|
|
if !agent_record.enabled
|
|
raise_node_error!("AI Agent '#{agent_record.name}' is disabled")
|
|
end
|
|
|
|
agent_instance = agent_record.class_instance.new
|
|
llm_model = resolve_llm_model(agent_record, config["llm_model_id"])
|
|
|
|
log.info("Agent: #{agent_record.name}")
|
|
log.info("LLM: #{llm_model.display_name} (#{llm_model.id})")
|
|
log.info("Prompt: #{prompt.to_s[0..200]}")
|
|
|
|
bot =
|
|
DiscourseAi::Agents::Bot.as(
|
|
Discourse.system_user,
|
|
agent: agent_instance,
|
|
model: llm_model,
|
|
)
|
|
|
|
bot_context =
|
|
DiscourseAi::Agents::BotContext.new(
|
|
user: Discourse.system_user,
|
|
messages: [{ type: :user, content: prompt }],
|
|
feature_name: "workflow",
|
|
)
|
|
|
|
result = +""
|
|
tool_calls = 0
|
|
|
|
bot.reply(bot_context) do |partial, _, type|
|
|
if type == :tool_call
|
|
tool_calls += 1
|
|
log.info("Tool call: #{partial}") if partial.is_a?(String)
|
|
elsif type == :structured_output
|
|
result = partial.to_s
|
|
elsif type.blank?
|
|
result << partial
|
|
end
|
|
end
|
|
|
|
log.info("Tool calls: #{tool_calls}") if tool_calls > 0
|
|
log.info("Result length: #{result.size} chars")
|
|
|
|
result
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|