robot_lab-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +110 -0
- data/Rakefile +8 -0
- data/docs/examples/rails-application.md +419 -0
- data/docs/guides/rails-integration.md +681 -0
- data/docs/index.md +75 -0
- data/examples/18_rails/.envrc +3 -0
- data/examples/18_rails/.gitignore +5 -0
- data/examples/18_rails/Gemfile +11 -0
- data/examples/18_rails/README.md +48 -0
- data/examples/18_rails/Rakefile +4 -0
- data/examples/18_rails/app/controllers/application_controller.rb +4 -0
- data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
- data/examples/18_rails/app/jobs/application_job.rb +4 -0
- data/examples/18_rails/app/jobs/robot_run_job.rb +19 -0
- data/examples/18_rails/app/models/application_record.rb +5 -0
- data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
- data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
- data/examples/18_rails/app/robots/chat_robot.rb +14 -0
- data/examples/18_rails/app/tools/time_tool.rb +9 -0
- data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
- data/examples/18_rails/app/views/chat/index.html.erb +67 -0
- data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
- data/examples/18_rails/bin/dev +7 -0
- data/examples/18_rails/bin/rails +6 -0
- data/examples/18_rails/bin/setup +15 -0
- data/examples/18_rails/config/application.rb +33 -0
- data/examples/18_rails/config/cable.yml +2 -0
- data/examples/18_rails/config/database.yml +5 -0
- data/examples/18_rails/config/environment.rb +4 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
- data/examples/18_rails/config/routes.rb +6 -0
- data/examples/18_rails/config.ru +4 -0
- data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
- data/lib/generators/robot_lab/install_generator.rb +63 -0
- data/lib/generators/robot_lab/job_generator.rb +28 -0
- data/lib/generators/robot_lab/robot_generator.rb +40 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +39 -0
- data/lib/generators/robot_lab/templates/job.rb.tt +21 -0
- data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/result_model.rb.tt +40 -0
- data/lib/generators/robot_lab/templates/robot.rb.tt +31 -0
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +15 -0
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +21 -0
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +42 -0
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +27 -0
- data/lib/robot_lab/rails/version.rb +7 -0
- data/lib/robot_lab/rails.rb +10 -0
- data/lib/robot_lab/rails_integration/engine.rb +23 -0
- data/lib/robot_lab/rails_integration/job.rb +109 -0
- data/lib/robot_lab/rails_integration/railtie.rb +36 -0
- data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +42 -0
- data/mkdocs.yml +117 -0
- metadata +158 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Generators
|
|
7
|
+
class JobGenerator < ::Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :queue, type: :string, default: "default",
|
|
11
|
+
desc: "ActiveJob queue name"
|
|
12
|
+
|
|
13
|
+
def create_job_file
|
|
14
|
+
template "robot_job.rb.tt", "app/jobs/#{file_name}_job.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def queue_name
|
|
20
|
+
options[:queue]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def robot_class_name
|
|
24
|
+
"#{class_name}Robot"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Generators
|
|
7
|
+
class RobotGenerator < ::Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :description, type: :string, default: nil,
|
|
11
|
+
desc: "Robot description"
|
|
12
|
+
class_option :routing, type: :boolean, default: false,
|
|
13
|
+
desc: "Generate a routing robot"
|
|
14
|
+
class_option :tools, type: :array, default: [],
|
|
15
|
+
desc: "List of tools to include"
|
|
16
|
+
|
|
17
|
+
def create_robot_file
|
|
18
|
+
if options[:routing]
|
|
19
|
+
template "routing_robot.rb.tt", "app/robots/#{file_name}_robot.rb"
|
|
20
|
+
else
|
|
21
|
+
template "robot.rb.tt", "app/robots/#{file_name}_robot.rb"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_test_file
|
|
26
|
+
template "robot_test.rb.tt", "test/robots/#{file_name}_robot_test.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def robot_description
|
|
32
|
+
options[:description] || "A helpful #{class_name.titleize} robot"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def robot_tools
|
|
36
|
+
options[:tools]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RobotLab Configuration
|
|
4
|
+
#
|
|
5
|
+
# RobotLab uses MywayConfig for configuration. Settings are loaded from:
|
|
6
|
+
#
|
|
7
|
+
# 1. Bundled defaults (lib/robot_lab/config/defaults.yml)
|
|
8
|
+
# 2. Environment-specific overrides (development, test, production)
|
|
9
|
+
# 3. XDG user config (~/.config/robot_lab/config.yml)
|
|
10
|
+
# 4. Project config (./config/robot_lab.yml)
|
|
11
|
+
# 5. Environment variables (ROBOT_LAB_* prefix)
|
|
12
|
+
#
|
|
13
|
+
# Create config/robot_lab.yml for project-specific settings:
|
|
14
|
+
#
|
|
15
|
+
# defaults:
|
|
16
|
+
# ruby_llm:
|
|
17
|
+
# anthropic_api_key: <%%= ENV['ANTHROPIC_API_KEY'] %>
|
|
18
|
+
# openai_api_key: <%%= ENV['OPENAI_API_KEY'] %>
|
|
19
|
+
# model: claude-sonnet-4
|
|
20
|
+
# request_timeout: 180
|
|
21
|
+
#
|
|
22
|
+
# development:
|
|
23
|
+
# ruby_llm:
|
|
24
|
+
# log_level: :debug
|
|
25
|
+
#
|
|
26
|
+
# test:
|
|
27
|
+
# streaming_enabled: false
|
|
28
|
+
# ruby_llm:
|
|
29
|
+
# model: claude-3-haiku-20240307
|
|
30
|
+
# request_timeout: 30
|
|
31
|
+
#
|
|
32
|
+
# Or use environment variables with double underscores for nested keys:
|
|
33
|
+
#
|
|
34
|
+
# ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
|
|
35
|
+
# ROBOT_LAB_RUBY_LLM__MODEL=claude-sonnet-4
|
|
36
|
+
#
|
|
37
|
+
|
|
38
|
+
# Set the RobotLab logger to use Rails.logger
|
|
39
|
+
RobotLab.config.logger = Rails.logger
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generic background job for executing any robot asynchronously.
|
|
4
|
+
#
|
|
5
|
+
# Inherits from RobotLab::Job — Turbo Stream wiring, thread persistence,
|
|
6
|
+
# and completion/error broadcasting are all handled by the base class.
|
|
7
|
+
#
|
|
8
|
+
# Pass robot_class: at enqueue time to select which robot to run, or
|
|
9
|
+
# generate a dedicated job with `rails generate robot_lab:job NAME` to
|
|
10
|
+
# bind a job class to a specific robot via the robot_class DSL.
|
|
11
|
+
#
|
|
12
|
+
# @example Enqueue from a controller
|
|
13
|
+
# RobotRunJob.perform_later(
|
|
14
|
+
# robot_class: "SupportRobot",
|
|
15
|
+
# message: params[:message],
|
|
16
|
+
# thread_id: session_id
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
class RobotRunJob < RobotLab::Job
|
|
20
|
+
queue_as :default
|
|
21
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRobotLabTables < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :robot_lab_threads do |t|
|
|
6
|
+
t.string :session_id, null: false, index: { unique: true }
|
|
7
|
+
t.text :initial_input
|
|
8
|
+
t.json :input_metadata, default: {}
|
|
9
|
+
t.json :state_data, default: {}
|
|
10
|
+
t.text :last_user_message
|
|
11
|
+
t.datetime :last_user_message_at
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
create_table :robot_lab_results do |t|
|
|
17
|
+
t.string :session_id, null: false, index: true
|
|
18
|
+
t.string :robot_name, null: false
|
|
19
|
+
t.integer :sequence_number, null: false, default: 0
|
|
20
|
+
t.json :output_data, default: []
|
|
21
|
+
t.json :tool_calls_data, default: []
|
|
22
|
+
t.string :stop_reason
|
|
23
|
+
t.string :checksum
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_index :robot_lab_results, [:session_id, :sequence_number]
|
|
29
|
+
add_foreign_key :robot_lab_results, :robot_lab_threads,
|
|
30
|
+
column: :session_id, primary_key: :session_id
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RobotLab Result Model
|
|
4
|
+
#
|
|
5
|
+
# Stores robot execution results for history persistence.
|
|
6
|
+
#
|
|
7
|
+
class RobotLabResult < ApplicationRecord
|
|
8
|
+
belongs_to :thread,
|
|
9
|
+
class_name: "RobotLabThread",
|
|
10
|
+
foreign_key: :session_id,
|
|
11
|
+
primary_key: :session_id
|
|
12
|
+
|
|
13
|
+
validates :session_id, presence: true
|
|
14
|
+
validates :robot_name, presence: true
|
|
15
|
+
validates :sequence_number, presence: true,
|
|
16
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
17
|
+
|
|
18
|
+
default_scope { order(sequence_number: :asc) }
|
|
19
|
+
|
|
20
|
+
def output_messages
|
|
21
|
+
(output_data || []).map do |data|
|
|
22
|
+
RobotLab::Message.from_hash(data.symbolize_keys)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tool_call_messages
|
|
27
|
+
(tool_calls_data || []).map do |data|
|
|
28
|
+
RobotLab::Message.from_hash(data.symbolize_keys)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_robot_result
|
|
33
|
+
RobotLab::RobotResult.new(
|
|
34
|
+
robot_name: robot_name,
|
|
35
|
+
output: output_messages,
|
|
36
|
+
tool_calls: tool_call_messages,
|
|
37
|
+
stop_reason: stop_reason
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= class_name %> Robot
|
|
4
|
+
#
|
|
5
|
+
# <%= robot_description %>
|
|
6
|
+
#
|
|
7
|
+
class <%= class_name %>Robot
|
|
8
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
9
|
+
You are a helpful <%= class_name.titleize %> assistant.
|
|
10
|
+
|
|
11
|
+
Your role is to assist users with their requests in a friendly and efficient manner.
|
|
12
|
+
PROMPT
|
|
13
|
+
|
|
14
|
+
def self.build(**options)
|
|
15
|
+
RobotLab.build(
|
|
16
|
+
name: "<%= file_name %>",
|
|
17
|
+
description: "<%= robot_description %>",
|
|
18
|
+
system_prompt: SYSTEM_PROMPT,
|
|
19
|
+
local_tools: tools,
|
|
20
|
+
**options
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.tools
|
|
25
|
+
[
|
|
26
|
+
<%- robot_tools.each do |tool| -%>
|
|
27
|
+
# <%= tool.camelize %>,
|
|
28
|
+
<%- end -%>
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Background job that runs <%= robot_class_name %> asynchronously.
|
|
4
|
+
#
|
|
5
|
+
# @example Enqueue from a controller
|
|
6
|
+
# <%= class_name %>Job.perform_later(
|
|
7
|
+
# message: params[:message],
|
|
8
|
+
# thread_id: session_id
|
|
9
|
+
# )
|
|
10
|
+
#
|
|
11
|
+
class <%= class_name %>Job < RobotLab::Job
|
|
12
|
+
queue_as :<%= queue_name %>
|
|
13
|
+
|
|
14
|
+
robot_class <%= robot_class_name %>
|
|
15
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class <%= class_name %>RobotTest < ActiveSupport::TestCase
|
|
6
|
+
def setup
|
|
7
|
+
@robot = <%= class_name %>Robot.build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
test "robot has correct name" do
|
|
11
|
+
assert_equal "<%= file_name %>", @robot.name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
test "robot has description" do
|
|
15
|
+
assert_not_nil @robot.description
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
test "robot has system prompt" do
|
|
19
|
+
assert_not_nil @robot.system_prompt
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= class_name %> Routing Robot
|
|
4
|
+
#
|
|
5
|
+
# <%= robot_description %>
|
|
6
|
+
#
|
|
7
|
+
class <%= class_name %>Robot < RobotLab::Robot
|
|
8
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
9
|
+
You are a routing robot that classifies user requests.
|
|
10
|
+
|
|
11
|
+
Analyze the user's request and respond with ONLY the category name.
|
|
12
|
+
Valid categories: billing, technical, general
|
|
13
|
+
PROMPT
|
|
14
|
+
|
|
15
|
+
def self.build(**options)
|
|
16
|
+
new(
|
|
17
|
+
name: "<%= file_name %>",
|
|
18
|
+
description: "<%= robot_description %>",
|
|
19
|
+
system_prompt: SYSTEM_PROMPT,
|
|
20
|
+
**options
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(result)
|
|
25
|
+
context = extract_run_context(result)
|
|
26
|
+
message = context.delete(:message)
|
|
27
|
+
|
|
28
|
+
robot_result = run(message, **context)
|
|
29
|
+
|
|
30
|
+
new_result = result
|
|
31
|
+
.with_context(@name.to_sym, robot_result)
|
|
32
|
+
.continue(robot_result)
|
|
33
|
+
|
|
34
|
+
category = robot_result.last_text_content.to_s.strip.downcase
|
|
35
|
+
|
|
36
|
+
case category
|
|
37
|
+
when /billing/ then new_result.activate(:billing)
|
|
38
|
+
when /technical/ then new_result.activate(:technical)
|
|
39
|
+
else new_result.activate(:general)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RobotLab Thread Model
|
|
4
|
+
#
|
|
5
|
+
# Stores conversation threads for history persistence.
|
|
6
|
+
#
|
|
7
|
+
class RobotLabThread < ApplicationRecord
|
|
8
|
+
has_many :results,
|
|
9
|
+
class_name: "RobotLabResult",
|
|
10
|
+
foreign_key: :session_id,
|
|
11
|
+
primary_key: :session_id,
|
|
12
|
+
dependent: :destroy
|
|
13
|
+
|
|
14
|
+
validates :session_id, presence: true, uniqueness: true
|
|
15
|
+
|
|
16
|
+
def self.find_or_create_by_session_id(id)
|
|
17
|
+
find_or_create_by(session_id: id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def last_result
|
|
21
|
+
results.order(sequence_number: :desc).first
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def message_count
|
|
25
|
+
results.count
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails/version"
|
|
4
|
+
require_relative "rails_integration/turbo_stream_callbacks"
|
|
5
|
+
|
|
6
|
+
if defined?(::Rails)
|
|
7
|
+
require_relative "rails_integration/engine"
|
|
8
|
+
require_relative "rails_integration/railtie"
|
|
9
|
+
require_relative "rails_integration/job"
|
|
10
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module RailsIntegration
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace RobotLab
|
|
7
|
+
|
|
8
|
+
initializer "robot_lab.configure" do |app|
|
|
9
|
+
app.config.robot_lab ||= ActiveSupport::OrderedOptions.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "robot_lab.add_autoload_paths", before: :set_autoload_paths do |app|
|
|
13
|
+
app.config.autoload_paths << root.join("app", "robots")
|
|
14
|
+
app.config.autoload_paths << root.join("app", "tools")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.generators do |g|
|
|
18
|
+
g.test_framework :minitest, fixture: false
|
|
19
|
+
g.fixture_replacement nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module RailsIntegration
|
|
5
|
+
# Base class for RobotLab background jobs.
|
|
6
|
+
#
|
|
7
|
+
# @example Minimal subclass using the robot_class DSL
|
|
8
|
+
# class SupportRobotJob < RobotLab::Job
|
|
9
|
+
# queue_as :default
|
|
10
|
+
# robot_class SupportRobot
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
class Job < ActiveJob::Base
|
|
14
|
+
def self.robot_class(klass = nil)
|
|
15
|
+
klass ? @robot_class = klass : @robot_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
retry_on StandardError, wait: 5.seconds, attempts: 3
|
|
19
|
+
discard_on ActiveJob::DeserializationError
|
|
20
|
+
|
|
21
|
+
def perform(message:, robot_class: nil, thread_id: nil, **context)
|
|
22
|
+
klass = resolve_robot_class(robot_class)
|
|
23
|
+
thread = setup_thread(thread_id, message)
|
|
24
|
+
robot = build_robot(klass, thread_id)
|
|
25
|
+
result = robot.run(message, **context)
|
|
26
|
+
|
|
27
|
+
if thread
|
|
28
|
+
persist_result(thread, result)
|
|
29
|
+
broadcast_completion(thread_id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
broadcast_error(thread_id, e) if thread_id
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def resolve_robot_class(runtime_class)
|
|
41
|
+
klass = runtime_class || self.class.robot_class
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"No robot class specified. Pass robot_class: to perform or set robot_class on the job class." \
|
|
44
|
+
unless klass
|
|
45
|
+
|
|
46
|
+
return klass if klass.is_a?(Class)
|
|
47
|
+
|
|
48
|
+
klass.to_s.constantize
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def setup_thread(thread_id, message)
|
|
52
|
+
return nil unless thread_id
|
|
53
|
+
|
|
54
|
+
thread = "RobotLabThread".constantize.find_or_create_by_session_id(thread_id)
|
|
55
|
+
thread.update!(last_user_message: message, last_user_message_at: Time.current)
|
|
56
|
+
thread
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_robot(klass, thread_id)
|
|
60
|
+
if thread_id && turbo_available?
|
|
61
|
+
stream_name = "robot_lab_thread_#{thread_id}"
|
|
62
|
+
on_content = TurboStreamCallbacks.build_content_callback(stream_name: stream_name)
|
|
63
|
+
on_tool_call = TurboStreamCallbacks.build_tool_call_callback(stream_name: stream_name)
|
|
64
|
+
klass.build(on_content: on_content, on_tool_call: on_tool_call)
|
|
65
|
+
else
|
|
66
|
+
klass.build
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def persist_result(thread, result)
|
|
71
|
+
sequence = thread.results.maximum(:sequence_number).to_i + 1
|
|
72
|
+
exported = result.export
|
|
73
|
+
|
|
74
|
+
thread.results.create!(
|
|
75
|
+
robot_name: result.robot_name,
|
|
76
|
+
sequence_number: sequence,
|
|
77
|
+
output_data: exported[:output],
|
|
78
|
+
tool_calls_data: exported[:tool_calls],
|
|
79
|
+
stop_reason: result.stop_reason,
|
|
80
|
+
checksum: result.checksum
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def broadcast_completion(thread_id)
|
|
85
|
+
return unless turbo_available?
|
|
86
|
+
|
|
87
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
88
|
+
"robot_lab_thread_#{thread_id}",
|
|
89
|
+
target: "robot_status",
|
|
90
|
+
html: "<div id=\"robot_status\"><span class=\"complete\">Complete</span></div>"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def broadcast_error(thread_id, error)
|
|
95
|
+
return unless turbo_available?
|
|
96
|
+
|
|
97
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
98
|
+
"robot_lab_thread_#{thread_id}",
|
|
99
|
+
target: "robot_errors",
|
|
100
|
+
html: "<div class=\"error\">#{ERB::Util.html_escape(error.message)}</div>"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def turbo_available?
|
|
105
|
+
defined?(Turbo::StreamsChannel)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module RailsIntegration
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
config.robot_lab = ActiveSupport::OrderedOptions.new
|
|
7
|
+
|
|
8
|
+
initializer "robot_lab.configuration" do |app|
|
|
9
|
+
RobotLab.configure do |config|
|
|
10
|
+
rails_config = app.config.robot_lab
|
|
11
|
+
|
|
12
|
+
config.default_model = rails_config.default_model if rails_config.default_model
|
|
13
|
+
config.default_provider = rails_config.default_provider if rails_config.default_provider
|
|
14
|
+
config.logger = ::Rails.logger
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "robot_lab.active_record" do
|
|
19
|
+
ActiveSupport.on_load(:active_record) do
|
|
20
|
+
# Extend ActiveRecord with RobotLab concerns if needed
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
rake_tasks do
|
|
25
|
+
path = File.expand_path("../tasks", __dir__)
|
|
26
|
+
Dir.glob("#{path}/**/*.rake").each { |f| load f }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
generators do
|
|
30
|
+
require "generators/robot_lab/install_generator"
|
|
31
|
+
require "generators/robot_lab/robot_generator"
|
|
32
|
+
require "generators/robot_lab/job_generator"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module RailsIntegration
|
|
5
|
+
# Stateless utility module that builds callback Procs for Turbo Stream broadcasting.
|
|
6
|
+
#
|
|
7
|
+
# Safe to require even without turbo-rails installed — checks at call time
|
|
8
|
+
# via `defined?(Turbo::StreamsChannel)`.
|
|
9
|
+
#
|
|
10
|
+
module TurboStreamCallbacks
|
|
11
|
+
def self.available?
|
|
12
|
+
defined?(Turbo::StreamsChannel) ? true : false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.build_content_callback(stream_name:, target: "robot_response")
|
|
16
|
+
->(chunk) {
|
|
17
|
+
content = chunk.respond_to?(:content) ? chunk.content : chunk.to_s
|
|
18
|
+
return unless content && TurboStreamCallbacks.available?
|
|
19
|
+
|
|
20
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
21
|
+
stream_name,
|
|
22
|
+
target: target,
|
|
23
|
+
html: ERB::Util.html_escape(content)
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.build_tool_call_callback(stream_name:, target: "robot_tools")
|
|
29
|
+
->(tool_call) {
|
|
30
|
+
return unless TurboStreamCallbacks.available?
|
|
31
|
+
|
|
32
|
+
name = tool_call.respond_to?(:name) ? tool_call.name : tool_call.to_s
|
|
33
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
34
|
+
stream_name,
|
|
35
|
+
target: target,
|
|
36
|
+
html: "<span class=\"tool-badge\">Using: #{ERB::Util.html_escape(name)}</span>"
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|