From 487d4e4ae06e7ef4539042422cb548f32a72e825 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 09:31:03 -0700 Subject: [PATCH 01/30] feat: add Rails generator for RubyLLM models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator creates Chat, Message, and ToolCall models with migrations for seamless Rails integration. Users can run 'rails generate ruby_llm:install' to automatically set up all required models and database tables. - Add install_generator with templates for models and migrations - Update Rails integration guide with generator instructions - Update README with two integration options - Update Railtie to register the generator - Add CLAUDE.md for agent assistance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 24 +++++++++++ README.md | 28 +++++++++++- docs/guides/rails.md | 29 +++++++++++-- .../ruby_llm/install/templates/chat_model.rb | 5 +++ .../templates/create_chats_migration.rb | 10 +++++ .../templates/create_messages_migration.rb | 16 +++++++ .../templates/create_tool_calls_migration.rb | 15 +++++++ .../ruby_llm/install/templates/initializer.rb | 16 +++++++ .../install/templates/message_model.rb | 5 +++ .../install/templates/tool_call_model.rb | 5 +++ lib/generators/ruby_llm/install_generator.rb | 43 +++++++++++++++++++ lib/ruby_llm/railtie.rb | 10 +++++ 12 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib/generators/ruby_llm/install/templates/chat_model.rb create mode 100644 lib/generators/ruby_llm/install/templates/create_chats_migration.rb create mode 100644 lib/generators/ruby_llm/install/templates/create_messages_migration.rb create mode 100644 lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb create mode 100644 lib/generators/ruby_llm/install/templates/initializer.rb create mode 100644 lib/generators/ruby_llm/install/templates/message_model.rb create mode 100644 lib/generators/ruby_llm/install/templates/tool_call_model.rb create mode 100644 lib/generators/ruby_llm/install_generator.rb diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..30f75488 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# RubyLLM Development Guidelines + +## Commands +- Run all tests: `bundle exec rspec` +- Run single test: `bundle exec rspec spec/path/to/file_spec.rb` +- Run specific test: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` +- Code style check: `bundle exec rubocop` +- Auto-fix style issues: `bundle exec rubocop -A` +- Record VCR cassettes: `bundle exec rake vcr:record[provider]` (where provider is openai, anthropic, gemini, etc.) + +## Style Guidelines +- Follow Standard Ruby style (https://github.com/testdouble/standard) +- Use frozen_string_literal comment at top of files +- Add YARD documentation comments for public methods +- Use proper error handling with specific error classes +- Follow consistent model naming conventions across providers +- Keep normalized model IDs (separate model from provider) +- Use consistent parameter naming across providers + +## Testing Practices +- Tests automatically create VCR cassettes based on their descriptions +- Use specific, descriptive test descriptions +- Check VCR cassettes for sensitive information +- Write tests for all new features and bug fixes \ No newline at end of file diff --git a/README.md b/README.md index f7da4bbd..ed773e4a 100644 --- a/README.md +++ b/README.md @@ -137,11 +137,37 @@ chat.with_model('gemini-2.0-flash').ask "What's your favorite algorithm?" ## Rails integration that makes sense +### Option 1: Create new models with the generator + +Simply run the generator to set up all required models: + +```bash +rails generate ruby_llm:install +``` + +This creates all necessary migrations, models, and database tables. Then just use them: + +```ruby +# In your controller +chat = Chat.create!(model_id: "gpt-4o-mini") +chat.ask("What's your favorite Ruby gem?") do |chunk| + Turbo::StreamsChannel.broadcast_append_to( + chat, target: "response", partial: "messages/chunk", locals: { chunk: chunk } + ) +end + +# That's it - chat history is automatically saved +``` + +### Option 2: Add to your existing models + +Or, add RubyLLM to your existing ActiveRecord models: + ```ruby # app/models/chat.rb class Chat < ApplicationRecord acts_as_chat - + # Works great with Turbo broadcasts_to ->(chat) { "chat_#{chat.id}" } end diff --git a/docs/guides/rails.md b/docs/guides/rails.md index ec36e407..ab8d12f1 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -12,9 +12,32 @@ RubyLLM provides seamless integration with Rails through ActiveRecord models. Th ## Setup -### 1. Create Migrations +### Using the Generator (Recommended) -First, create the necessary tables in your database: +The easiest way to set up RubyLLM with Rails is to use the built-in generator: + +```bash +rails generate ruby_llm:install +``` + +This will automatically: +1. Create the necessary migrations for chats, messages, and tool calls +2. Create model files with appropriate `acts_as_*` methods +3. Set up proper relationships between models + +After running the generator, simply run the migrations: + +```bash +rails db:migrate +``` + +### Manual Setup + +If you prefer to set up manually or need to customize the implementation, follow these steps: + +#### 1. Create Migrations + +Create the necessary tables in your database: ```ruby # db/migrate/YYYYMMDDHHMMSS_create_chats.rb @@ -65,7 +88,7 @@ Run the migrations: rails db:migrate ``` -### 2. Set Up Models +#### 2. Set Up Models Create the model classes: diff --git a/lib/generators/ruby_llm/install/templates/chat_model.rb b/lib/generators/ruby_llm/install/templates/chat_model.rb new file mode 100644 index 00000000..90a4f03a --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/chat_model.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Chat < ApplicationRecord + acts_as_chat +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/create_chats_migration.rb b/lib/generators/ruby_llm/install/templates/create_chats_migration.rb new file mode 100644 index 00000000..6b28c111 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/create_chats_migration.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CreateChats < ActiveRecord::Migration<%= migration_version %> + def change + create_table :chats do |t| + t.string :model_id + t.timestamps + end + end +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb new file mode 100644 index 00000000..a985d119 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateMessages < ActiveRecord::Migration<%= migration_version %> + def change + create_table :messages do |t| + t.references :chat, null: false, foreign_key: true + t.string :role + t.text :content + t.string :model_id + t.integer :input_tokens + t.integer :output_tokens + t.references :tool_call + t.timestamps + end + end +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb new file mode 100644 index 00000000..844c8627 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateToolCalls < ActiveRecord::Migration<%= migration_version %> + def change + create_table :tool_calls do |t| + t.references :message, null: false, foreign_key: true + t.string :tool_call_id, null: false + t.string :name, null: false + t.jsonb :arguments, default: {} + t.timestamps + end + + add_index :tool_calls, :tool_call_id + end +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/initializer.rb b/lib/generators/ruby_llm/install/templates/initializer.rb new file mode 100644 index 00000000..26f10e77 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/initializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# RubyLLM configuration +RubyLLM.configure do |config| + # Set your API keys here or use environment variables + # config.openai_api_key = ENV["OPENAI_API_KEY"] + # config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] + # config.gemini_api_key = ENV["GEMINI_API_KEY"] + # config.deepseek_api_key = ENV["DEEPSEEK_API_KEY"] + + # Uncomment to set a default model + # config.default_model = "gpt-4o-mini" + + # Uncomment to set default options + # config.default_options = { temperature: 0.7 } +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/message_model.rb b/lib/generators/ruby_llm/install/templates/message_model.rb new file mode 100644 index 00000000..fc3bcbba --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/message_model.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Message < ApplicationRecord + acts_as_message +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/tool_call_model.rb b/lib/generators/ruby_llm/install/templates/tool_call_model.rb new file mode 100644 index 00000000..693b98c8 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/tool_call_model.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ToolCall < ApplicationRecord + acts_as_tool_call +end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb new file mode 100644 index 00000000..bb98d201 --- /dev/null +++ b/lib/generators/ruby_llm/install_generator.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails/generators" +require "rails/generators/active_record" + +module RubyLLM + # Generator for RubyLLM Rails models and migrations + class InstallGenerator < Rails::Generators::Base + include Rails::Generators::Migration + + source_root File.expand_path("install/templates", __dir__) + + desc "Creates model files for Chat, Message, and ToolCall, and creates migrations for RubyLLM Rails integration" + + def self.next_migration_number(dirname) + ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + def migration_version + "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" + end + + def create_migration_files + migration_template "create_chats_migration.rb", "db/migrate/create_chats.rb" + migration_template "create_messages_migration.rb", "db/migrate/create_messages.rb" + migration_template "create_tool_calls_migration.rb", "db/migrate/create_tool_calls.rb" + end + + def create_model_files + template "chat_model.rb", "app/models/chat.rb" + template "message_model.rb", "app/models/message.rb" + template "tool_call_model.rb", "app/models/tool_call.rb" + end + + def create_initializer + template "initializer.rb", "config/initializers/ruby_llm.rb" + end + + def show_readme + readme "README.md" + end + end +end \ No newline at end of file diff --git a/lib/ruby_llm/railtie.rb b/lib/ruby_llm/railtie.rb index e1b8610c..ce5e5479 100644 --- a/lib/ruby_llm/railtie.rb +++ b/lib/ruby_llm/railtie.rb @@ -8,5 +8,15 @@ class Railtie < Rails::Railtie include RubyLLM::ActiveRecord::ActsAs end end + + # Include rake tasks if applicable + rake_tasks do + # Task definitions go here if needed + end + + # Register generators + generators do + require 'generators/ruby_llm/install_generator' + end end end From 3577f31785fc0033ea6f5551924b233e46eb81c7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 09:39:29 -0700 Subject: [PATCH 02/30] test: add generator template tests and fix zeitwerk warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added tests for the Rails generator template files to ensure they contain the expected content. Fixed a Zeitwerk warning by ignoring the generators directory in the loader. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CONTRIBUTING.md | 3 + lib/ruby_llm.rb | 1 + .../ruby_llm/template_files_spec.rb | 95 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 spec/lib/generators/ruby_llm/template_files_spec.rb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8396f6cd..2b6cb1fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,6 +133,9 @@ bundle exec rspec # Run a specific test file bundle exec rspec spec/ruby_llm/chat_spec.rb + +# Run generator template tests +bundle exec rspec spec/lib/generators/ruby_llm/template_files_spec.rb ``` ### Recording VCR Cassettes diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index a58fddce..7eeff7c6 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -20,6 +20,7 @@ loader.ignore("#{__dir__}/tasks") loader.ignore("#{__dir__}/ruby_llm/railtie") loader.ignore("#{__dir__}/ruby_llm/active_record") +loader.ignore("#{__dir__}/generators") loader.setup # A delightful Ruby interface to modern AI language models. diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb new file mode 100644 index 00000000..cc3f77b2 --- /dev/null +++ b/spec/lib/generators/ruby_llm/template_files_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' + +RSpec.describe "Generator template files", type: :generator do + # Use the actual template directory + let(:template_dir) { "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install/templates" } + + describe "migration templates" do + it "has expected migration template files" do + expected_files = [ + "create_chats_migration.rb", + "create_messages_migration.rb", + "create_tool_calls_migration.rb" + ] + + expected_files.each do |file| + expect(File.exist?(File.join(template_dir, file))).to be(true), "Expected template file #{file} to exist" + end + end + + it "has proper migration content" do + chat_migration = File.read(File.join(template_dir, "create_chats_migration.rb")) + expect(chat_migration).to include("create_table :chats") + expect(chat_migration).to include("t.string :model_id") + + message_migration = File.read(File.join(template_dir, "create_messages_migration.rb")) + expect(message_migration).to include("create_table :messages") + expect(message_migration).to include("t.references :chat") + expect(message_migration).to include("t.string :role") + expect(message_migration).to include("t.text :content") + + tool_call_migration = File.read(File.join(template_dir, "create_tool_calls_migration.rb")) + expect(tool_call_migration).to include("create_table :tool_calls") + expect(tool_call_migration).to include("t.references :message") + expect(tool_call_migration).to include("t.string :tool_call_id") + expect(tool_call_migration).to include("t.string :name") + expect(tool_call_migration).to include("t.jsonb :arguments") + end + end + + describe "model templates" do + it "has expected model template files" do + expected_files = [ + "chat_model.rb", + "message_model.rb", + "tool_call_model.rb" + ] + + expected_files.each do |file| + expect(File.exist?(File.join(template_dir, file))).to be(true), "Expected template file #{file} to exist" + end + end + + it "has proper acts_as declarations in model templates" do + chat_content = File.read(File.join(template_dir, "chat_model.rb")) + expect(chat_content).to include("acts_as_chat") + + message_content = File.read(File.join(template_dir, "message_model.rb")) + expect(message_content).to include("acts_as_message") + + tool_call_content = File.read(File.join(template_dir, "tool_call_model.rb")) + expect(tool_call_content).to include("acts_as_tool_call") + end + end + + describe "initializer template" do + it "has expected initializer template file" do + expect(File.exist?(File.join(template_dir, "initializer.rb"))).to be(true) + end + + it "has proper configuration content" do + initializer_content = File.read(File.join(template_dir, "initializer.rb")) + expect(initializer_content).to include("RubyLLM.configure do |config|") + expect(initializer_content).to include("config.openai_api_key") + expect(initializer_content).to include("ENV[\"OPENAI_API_KEY\"]") + expect(initializer_content).to include("config.anthropic_api_key") + end + end + + describe "generator file structure" do + it "has proper directory structure" do + generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" + expect(File.exist?(generator_file)).to be(true) + + generator_content = File.read(generator_file) + expect(generator_content).to include("class InstallGenerator < Rails::Generators::Base") + expect(generator_content).to include("include Rails::Generators::Migration") + expect(generator_content).to include("def create_migration_files") + expect(generator_content).to include("def create_model_files") + expect(generator_content).to include("def create_initializer") + end + end +end \ No newline at end of file From 24467aa453fbae4cd4f41895ca962f707d9277bb Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 09:41:24 -0700 Subject: [PATCH 03/30] feat: add cross-database support for JSON columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated the tool_calls migration to detect and use the appropriate JSON column type based on the database adapter: - Uses jsonb for PostgreSQL databases (better performance and indexing) - Falls back to standard json type for other databases (MySQL, SQLite, etc.) - Added postgresql? detection method with proper error handling - Added comprehensive tests for database adapter detection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../templates/create_tool_calls_migration.rb | 10 +++++++++- lib/generators/ruby_llm/install_generator.rb | 6 ++++++ .../ruby_llm/template_files_spec.rb | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb index 844c8627..4584c9dc 100644 --- a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb +++ b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb @@ -1,12 +1,20 @@ # frozen_string_literal: true +# Migration for creating tool_calls table with database-specific JSON handling class CreateToolCalls < ActiveRecord::Migration<%= migration_version %> def change create_table :tool_calls do |t| t.references :message, null: false, foreign_key: true t.string :tool_call_id, null: false t.string :name, null: false - t.jsonb :arguments, default: {} + + # Use the appropriate JSON column type for the database + if postgresql? + t.jsonb :arguments, default: {} + else + t.json :arguments, default: {} + end + t.timestamps end diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index bb98d201..63c3c74e 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -19,6 +19,12 @@ def self.next_migration_number(dirname) def migration_version "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" end + + def postgresql? + ActiveRecord::Base.connection.adapter_name.downcase.include?("postgresql") + rescue + false + end def create_migration_files migration_template "create_chats_migration.rb", "db/migrate/create_chats.rb" diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb index cc3f77b2..921fee34 100644 --- a/spec/lib/generators/ruby_llm/template_files_spec.rb +++ b/spec/lib/generators/ruby_llm/template_files_spec.rb @@ -36,7 +36,13 @@ expect(tool_call_migration).to include("t.references :message") expect(tool_call_migration).to include("t.string :tool_call_id") expect(tool_call_migration).to include("t.string :name") + + # Should check for database-agnostic JSON handling + expect(tool_call_migration).to include("if postgresql?") expect(tool_call_migration).to include("t.jsonb :arguments") + expect(tool_call_migration).to include("else") + expect(tool_call_migration).to include("t.json :arguments") + expect(tool_call_migration).to include("end") end end @@ -92,4 +98,17 @@ expect(generator_content).to include("def create_initializer") end end + + describe "database adapter detection" do + it "has proper postgresql detection method" do + generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" + generator_content = File.read(generator_file) + + # Check proper postgresql? method implementation + expect(generator_content).to include("def postgresql?") + expect(generator_content).to include("ActiveRecord::Base.connection.adapter_name.downcase.include?(\"postgresql\")") + expect(generator_content).to include("rescue") + expect(generator_content).to include("false") + end + end end \ No newline at end of file From 2d509092c353c896517548a3675fdfae53439280 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 09:44:23 -0700 Subject: [PATCH 04/30] fix: ensure proper migration order for database schema test --- .../templates/create_messages_migration.rb | 4 ++- .../templates/create_tool_calls_migration.rb | 3 +- lib/generators/ruby_llm/install_generator.rb | 16 ++++++++++- .../ruby_llm/template_files_spec.rb | 28 ++++++++++++++++++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb index a985d119..a284bf0b 100644 --- a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb +++ b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# This migration must be run AFTER create_chats and create_tool_calls migrations +# to ensure proper foreign key references class CreateMessages < ActiveRecord::Migration<%= migration_version %> def change create_table :messages do |t| @@ -9,7 +11,7 @@ def change t.string :model_id t.integer :input_tokens t.integer :output_tokens - t.references :tool_call + t.references :tool_call, foreign_key: true t.timestamps end end diff --git a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb index 4584c9dc..22411766 100644 --- a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb +++ b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb @@ -4,7 +4,8 @@ class CreateToolCalls < ActiveRecord::Migration<%= migration_version %> def change create_table :tool_calls do |t| - t.references :message, null: false, foreign_key: true + # No reference to message to avoid circular references + # Messages will reference tool_calls, not the other way around t.string :tool_call_id, null: false t.string :name, null: false diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 63c3c74e..5026d7d9 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -27,9 +27,23 @@ def postgresql? end def create_migration_files + # Create migrations in the correct order with sequential timestamps + # to ensure proper foreign key references: + # 1. First create chats (no dependencies) + # 2. Then create tool_calls (will be referenced by messages) + # 3. Finally create messages (depends on both chats and tool_calls) + + # Use a fixed timestamp for testing and to ensure they're sequential + @migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S") migration_template "create_chats_migration.rb", "db/migrate/create_chats.rb" - migration_template "create_messages_migration.rb", "db/migrate/create_messages.rb" + + # Increment timestamp for the next migration + @migration_number = (@migration_number.to_i + 1).to_s migration_template "create_tool_calls_migration.rb", "db/migrate/create_tool_calls.rb" + + # Increment timestamp again for the final migration + @migration_number = (@migration_number.to_i + 2).to_s + migration_template "create_messages_migration.rb", "db/migrate/create_messages.rb" end def create_model_files diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb index 921fee34..11879214 100644 --- a/spec/lib/generators/ruby_llm/template_files_spec.rb +++ b/spec/lib/generators/ruby_llm/template_files_spec.rb @@ -30,10 +30,11 @@ expect(message_migration).to include("t.references :chat") expect(message_migration).to include("t.string :role") expect(message_migration).to include("t.text :content") + expect(message_migration).to include("t.references :tool_call") tool_call_migration = File.read(File.join(template_dir, "create_tool_calls_migration.rb")) expect(tool_call_migration).to include("create_table :tool_calls") - expect(tool_call_migration).to include("t.references :message") + expect(tool_call_migration).to include("# No reference to message to avoid circular references") expect(tool_call_migration).to include("t.string :tool_call_id") expect(tool_call_migration).to include("t.string :name") @@ -97,6 +98,31 @@ expect(generator_content).to include("def create_model_files") expect(generator_content).to include("def create_initializer") end + + it "creates migrations in the correct order" do + generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" + generator_content = File.read(generator_file) + + # Check for correct order in migration creation + # 1. First chats table (no dependencies) + # 2. Then tool_calls table (will be referenced by messages) + # 3. Finally messages table (depends on both chats and tool_calls) + + # Simply check the order of template calls + # Chats should come before tool_calls, which should come before messages + chats_position = generator_content.index('create_chats.rb') + tool_calls_position = generator_content.index('create_tool_calls.rb') + messages_position = generator_content.index('create_messages.rb') + + # Verify order: chats -> tool_calls -> messages + expect(chats_position).to be < tool_calls_position + expect(tool_calls_position).to be < messages_position + + # Also test that the method enforces sequential timestamps + expect(generator_content).to include("@migration_number = Time.now.utc.strftime") + expect(generator_content).to include("@migration_number = (@migration_number.to_i + 1).to_s") + expect(generator_content).to include("@migration_number = (@migration_number.to_i + 2).to_s") + end end describe "database adapter detection" do From 6e68d3628459065564f14191a7788a56123b4a79 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 09:55:31 -0700 Subject: [PATCH 05/30] Add README template for generator output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a helpful README template that's displayed after successful generator installation, providing users with clear next steps and examples of how to use the generated models. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../ruby_llm/install/templates/README.md | 55 +++++++++++++++++++ .../ruby_llm/template_files_spec.rb | 17 ++++++ 2 files changed, 72 insertions(+) create mode 100644 lib/generators/ruby_llm/install/templates/README.md diff --git a/lib/generators/ruby_llm/install/templates/README.md b/lib/generators/ruby_llm/install/templates/README.md new file mode 100644 index 00000000..fc4c1586 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/README.md @@ -0,0 +1,55 @@ +# RubyLLM Rails Setup Complete! + +Thanks for installing RubyLLM in your Rails application. Here's what was created: + +## Models + +- `Chat` - Stores chat sessions and their associated model ID +- `Message` - Stores individual messages in a chat +- `ToolCall` - Stores tool calls made by language models + +## Next Steps + +1. **Run migrations:** + ```bash + rails db:migrate + ``` + +2. **Set your API keys** in `config/initializers/ruby_llm.rb` or using environment variables: + ```ruby + # config/initializers/ruby_llm.rb + RubyLLM.configure do |config| + config.openai_api_key = ENV["OPENAI_API_KEY"] + config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] + # etc. + end + ``` + +3. **Start using RubyLLM in your code:** + ```ruby + # Create a new chat + chat = Chat.create!(model_id: "gpt-4o-mini") + + # Ask a question + response = chat.ask("What's the best Ruby web framework?") + + # Get chat history + chat.messages + ``` + +4. **For streaming responses** with ActionCable or Turbo: + ```ruby + chat.ask("Tell me about Ruby on Rails") do |chunk| + Turbo::StreamsChannel.broadcast_append_to( + chat, target: "response", partial: "messages/chunk", locals: { chunk: chunk } + ) + end + ``` + +## Advanced Usage + +- Add more fields to your models as needed +- Customize the views to match your application design +- Create a controller for chat interactions + +For more information, visit the [RubyLLM Documentation](https://github.com/crmne/ruby_llm) \ No newline at end of file diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb index 11879214..83b7c89e 100644 --- a/spec/lib/generators/ruby_llm/template_files_spec.rb +++ b/spec/lib/generators/ruby_llm/template_files_spec.rb @@ -86,6 +86,22 @@ end end + describe "README template" do + it "has a README template file" do + expect(File.exist?(File.join(template_dir, "README.md"))).to be(true) + end + + it "has helpful post-installation instructions" do + readme_content = File.read(File.join(template_dir, "README.md")) + expect(readme_content).to include("RubyLLM Rails Setup Complete") + expect(readme_content).to include("Run migrations") + expect(readme_content).to include("rails db:migrate") + expect(readme_content).to include("Set your API keys") + expect(readme_content).to include("Start using RubyLLM in your code") + expect(readme_content).to include("For streaming responses") + end + end + describe "generator file structure" do it "has proper directory structure" do generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" @@ -97,6 +113,7 @@ expect(generator_content).to include("def create_migration_files") expect(generator_content).to include("def create_model_files") expect(generator_content).to include("def create_initializer") + expect(generator_content).to include("def show_readme") end it "creates migrations in the correct order" do From dacb14887223063297c42c2bab9aeb978babdb44 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 09:56:27 -0700 Subject: [PATCH 06/30] Remove unnecessary comments from create_tool_calls_migration.rb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback to remove redundant comments from the migration template. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../ruby_llm/install/templates/create_tool_calls_migration.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb index 22411766..d0234719 100644 --- a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb +++ b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb @@ -4,8 +4,6 @@ class CreateToolCalls < ActiveRecord::Migration<%= migration_version %> def change create_table :tool_calls do |t| - # No reference to message to avoid circular references - # Messages will reference tool_calls, not the other way around t.string :tool_call_id, null: false t.string :name, null: false From f469f607e1c9f4cc02a5c74917aafd036c80ac82 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 10:00:40 -0700 Subject: [PATCH 07/30] Update generator templates to use .tt extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed all template files to use .tt extension following Rails convention - Updated generator code and tests to reference the new template files - Fixed style issues in the generator code according to Rubocop 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../{chat_model.rb => chat_model.rb.tt} | 0 ...ration.rb => create_chats_migration.rb.tt} | 0 ...ion.rb => create_messages_migration.rb.tt} | 0 ...n.rb => create_tool_calls_migration.rb.tt} | 0 .../{initializer.rb => initializer.rb.tt} | 0 .../{message_model.rb => message_model.rb.tt} | 0 ...ol_call_model.rb => tool_call_model.rb.tt} | 0 lib/generators/ruby_llm/install_generator.rb | 44 +++++++++---------- .../ruby_llm/template_files_spec.rb | 29 ++++++------ 9 files changed, 36 insertions(+), 37 deletions(-) rename lib/generators/ruby_llm/install/templates/{chat_model.rb => chat_model.rb.tt} (100%) rename lib/generators/ruby_llm/install/templates/{create_chats_migration.rb => create_chats_migration.rb.tt} (100%) rename lib/generators/ruby_llm/install/templates/{create_messages_migration.rb => create_messages_migration.rb.tt} (100%) rename lib/generators/ruby_llm/install/templates/{create_tool_calls_migration.rb => create_tool_calls_migration.rb.tt} (100%) rename lib/generators/ruby_llm/install/templates/{initializer.rb => initializer.rb.tt} (100%) rename lib/generators/ruby_llm/install/templates/{message_model.rb => message_model.rb.tt} (100%) rename lib/generators/ruby_llm/install/templates/{tool_call_model.rb => tool_call_model.rb.tt} (100%) diff --git a/lib/generators/ruby_llm/install/templates/chat_model.rb b/lib/generators/ruby_llm/install/templates/chat_model.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/chat_model.rb rename to lib/generators/ruby_llm/install/templates/chat_model.rb.tt diff --git a/lib/generators/ruby_llm/install/templates/create_chats_migration.rb b/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/create_chats_migration.rb rename to lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt diff --git a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/create_messages_migration.rb rename to lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt diff --git a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb rename to lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt diff --git a/lib/generators/ruby_llm/install/templates/initializer.rb b/lib/generators/ruby_llm/install/templates/initializer.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/initializer.rb rename to lib/generators/ruby_llm/install/templates/initializer.rb.tt diff --git a/lib/generators/ruby_llm/install/templates/message_model.rb b/lib/generators/ruby_llm/install/templates/message_model.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/message_model.rb rename to lib/generators/ruby_llm/install/templates/message_model.rb.tt diff --git a/lib/generators/ruby_llm/install/templates/tool_call_model.rb b/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/tool_call_model.rb rename to lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 5026d7d9..5c291e72 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -1,28 +1,28 @@ # frozen_string_literal: true -require "rails/generators" -require "rails/generators/active_record" +require 'rails/generators' +require 'rails/generators/active_record' module RubyLLM # Generator for RubyLLM Rails models and migrations class InstallGenerator < Rails::Generators::Base include Rails::Generators::Migration - source_root File.expand_path("install/templates", __dir__) - - desc "Creates model files for Chat, Message, and ToolCall, and creates migrations for RubyLLM Rails integration" + source_root File.expand_path('install/templates', __dir__) + + desc 'Creates model files for Chat, Message, and ToolCall, and creates migrations for RubyLLM Rails integration' def self.next_migration_number(dirname) ActiveRecord::Generators::Base.next_migration_number(dirname) end - + def migration_version "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" end - + def postgresql? - ActiveRecord::Base.connection.adapter_name.downcase.include?("postgresql") - rescue + ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') + rescue StandardError false end @@ -32,32 +32,32 @@ def create_migration_files # 1. First create chats (no dependencies) # 2. Then create tool_calls (will be referenced by messages) # 3. Finally create messages (depends on both chats and tool_calls) - + # Use a fixed timestamp for testing and to ensure they're sequential - @migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S") - migration_template "create_chats_migration.rb", "db/migrate/create_chats.rb" - + @migration_number = Time.now.utc.strftime('%Y%m%d%H%M%S') + migration_template 'create_chats_migration.rb.tt', 'db/migrate/create_chats.rb' + # Increment timestamp for the next migration @migration_number = (@migration_number.to_i + 1).to_s - migration_template "create_tool_calls_migration.rb", "db/migrate/create_tool_calls.rb" - + migration_template 'create_tool_calls_migration.rb.tt', 'db/migrate/create_tool_calls.rb' + # Increment timestamp again for the final migration @migration_number = (@migration_number.to_i + 2).to_s - migration_template "create_messages_migration.rb", "db/migrate/create_messages.rb" + migration_template 'create_messages_migration.rb.tt', 'db/migrate/create_messages.rb' end def create_model_files - template "chat_model.rb", "app/models/chat.rb" - template "message_model.rb", "app/models/message.rb" - template "tool_call_model.rb", "app/models/tool_call.rb" + template 'chat_model.rb.tt', 'app/models/chat.rb' + template 'message_model.rb.tt', 'app/models/message.rb' + template 'tool_call_model.rb.tt', 'app/models/tool_call.rb' end def create_initializer - template "initializer.rb", "config/initializers/ruby_llm.rb" + template 'initializer.rb.tt', 'config/initializers/ruby_llm.rb' end def show_readme - readme "README.md" + readme 'README.md' end end -end \ No newline at end of file +end diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb index 83b7c89e..e9881152 100644 --- a/spec/lib/generators/ruby_llm/template_files_spec.rb +++ b/spec/lib/generators/ruby_llm/template_files_spec.rb @@ -10,9 +10,9 @@ describe "migration templates" do it "has expected migration template files" do expected_files = [ - "create_chats_migration.rb", - "create_messages_migration.rb", - "create_tool_calls_migration.rb" + "create_chats_migration.rb.tt", + "create_messages_migration.rb.tt", + "create_tool_calls_migration.rb.tt" ] expected_files.each do |file| @@ -21,20 +21,19 @@ end it "has proper migration content" do - chat_migration = File.read(File.join(template_dir, "create_chats_migration.rb")) + chat_migration = File.read(File.join(template_dir, "create_chats_migration.rb.tt")) expect(chat_migration).to include("create_table :chats") expect(chat_migration).to include("t.string :model_id") - message_migration = File.read(File.join(template_dir, "create_messages_migration.rb")) + message_migration = File.read(File.join(template_dir, "create_messages_migration.rb.tt")) expect(message_migration).to include("create_table :messages") expect(message_migration).to include("t.references :chat") expect(message_migration).to include("t.string :role") expect(message_migration).to include("t.text :content") expect(message_migration).to include("t.references :tool_call") - tool_call_migration = File.read(File.join(template_dir, "create_tool_calls_migration.rb")) + tool_call_migration = File.read(File.join(template_dir, "create_tool_calls_migration.rb.tt")) expect(tool_call_migration).to include("create_table :tool_calls") - expect(tool_call_migration).to include("# No reference to message to avoid circular references") expect(tool_call_migration).to include("t.string :tool_call_id") expect(tool_call_migration).to include("t.string :name") @@ -50,9 +49,9 @@ describe "model templates" do it "has expected model template files" do expected_files = [ - "chat_model.rb", - "message_model.rb", - "tool_call_model.rb" + "chat_model.rb.tt", + "message_model.rb.tt", + "tool_call_model.rb.tt" ] expected_files.each do |file| @@ -61,24 +60,24 @@ end it "has proper acts_as declarations in model templates" do - chat_content = File.read(File.join(template_dir, "chat_model.rb")) + chat_content = File.read(File.join(template_dir, "chat_model.rb.tt")) expect(chat_content).to include("acts_as_chat") - message_content = File.read(File.join(template_dir, "message_model.rb")) + message_content = File.read(File.join(template_dir, "message_model.rb.tt")) expect(message_content).to include("acts_as_message") - tool_call_content = File.read(File.join(template_dir, "tool_call_model.rb")) + tool_call_content = File.read(File.join(template_dir, "tool_call_model.rb.tt")) expect(tool_call_content).to include("acts_as_tool_call") end end describe "initializer template" do it "has expected initializer template file" do - expect(File.exist?(File.join(template_dir, "initializer.rb"))).to be(true) + expect(File.exist?(File.join(template_dir, "initializer.rb.tt"))).to be(true) end it "has proper configuration content" do - initializer_content = File.read(File.join(template_dir, "initializer.rb")) + initializer_content = File.read(File.join(template_dir, "initializer.rb.tt")) expect(initializer_content).to include("RubyLLM.configure do |config|") expect(initializer_content).to include("config.openai_api_key") expect(initializer_content).to include("ENV[\"OPENAI_API_KEY\"]") From 844614bbdccbed096b8d4745edd1521e0f4a9ab4 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 10:02:52 -0700 Subject: [PATCH 08/30] Improve test organization and fix style issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split large tests into smaller focused tests - Fix string quotes to follow style guidelines - Remove trailing whitespace - Improve test readability and organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../ruby_llm/template_files_spec.rb | 274 ++++++++++-------- 1 file changed, 158 insertions(+), 116 deletions(-) diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb index e9881152..2c9c0ea9 100644 --- a/spec/lib/generators/ruby_llm/template_files_spec.rb +++ b/spec/lib/generators/ruby_llm/template_files_spec.rb @@ -2,155 +2,197 @@ require 'spec_helper' require 'fileutils' +require 'generators/ruby_llm/install_generator' -RSpec.describe "Generator template files", type: :generator do +RSpec.describe RubyLLM::InstallGenerator, type: :generator do # Use the actual template directory - let(:template_dir) { "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install/templates" } - - describe "migration templates" do - it "has expected migration template files" do + let(:template_dir) { '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install/templates' } + + describe 'migration templates' do + it 'has expected migration template files' do expected_files = [ - "create_chats_migration.rb.tt", - "create_messages_migration.rb.tt", - "create_tool_calls_migration.rb.tt" + 'create_chats_migration.rb.tt', + 'create_messages_migration.rb.tt', + 'create_tool_calls_migration.rb.tt' ] - + expected_files.each do |file| expect(File.exist?(File.join(template_dir, file))).to be(true), "Expected template file #{file} to exist" end end - - it "has proper migration content" do - chat_migration = File.read(File.join(template_dir, "create_chats_migration.rb.tt")) - expect(chat_migration).to include("create_table :chats") - expect(chat_migration).to include("t.string :model_id") - - message_migration = File.read(File.join(template_dir, "create_messages_migration.rb.tt")) - expect(message_migration).to include("create_table :messages") - expect(message_migration).to include("t.references :chat") - expect(message_migration).to include("t.string :role") - expect(message_migration).to include("t.text :content") - expect(message_migration).to include("t.references :tool_call") - - tool_call_migration = File.read(File.join(template_dir, "create_tool_calls_migration.rb.tt")) - expect(tool_call_migration).to include("create_table :tool_calls") - expect(tool_call_migration).to include("t.string :tool_call_id") - expect(tool_call_migration).to include("t.string :name") - - # Should check for database-agnostic JSON handling - expect(tool_call_migration).to include("if postgresql?") - expect(tool_call_migration).to include("t.jsonb :arguments") - expect(tool_call_migration).to include("else") - expect(tool_call_migration).to include("t.json :arguments") - expect(tool_call_migration).to include("end") + + it 'has proper chats migration content' do + chat_migration = File.read(File.join(template_dir, 'create_chats_migration.rb.tt')) + expect(chat_migration).to include('create_table :chats') + expect(chat_migration).to include('t.string :model_id') + end + + it 'has proper messages migration content' do + message_migration = File.read(File.join(template_dir, 'create_messages_migration.rb.tt')) + expect(message_migration).to include('create_table :messages') + expect(message_migration).to include('t.references :chat') + expect(message_migration).to include('t.string :role') + expect(message_migration).to include('t.text :content') + end + + it 'has proper tool_calls migration content' do + tool_call_migration = File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) + expect(tool_call_migration).to include('create_table :tool_calls') + expect(tool_call_migration).to include('t.string :tool_call_id') + expect(tool_call_migration).to include('t.string :name') + end + + it 'supports database-agnostic JSON handling in migrations' do + tool_call_migration = File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) + expect(tool_call_migration).to include('if postgresql?') + expect(tool_call_migration).to include('t.jsonb :arguments') + expect(tool_call_migration).to include('else') + expect(tool_call_migration).to include('t.json :arguments') end end - - describe "model templates" do - it "has expected model template files" do + + describe 'model templates' do + it 'has expected model template files' do expected_files = [ - "chat_model.rb.tt", - "message_model.rb.tt", - "tool_call_model.rb.tt" + 'chat_model.rb.tt', + 'message_model.rb.tt', + 'tool_call_model.rb.tt' ] - + expected_files.each do |file| expect(File.exist?(File.join(template_dir, file))).to be(true), "Expected template file #{file} to exist" end end - - it "has proper acts_as declarations in model templates" do - chat_content = File.read(File.join(template_dir, "chat_model.rb.tt")) - expect(chat_content).to include("acts_as_chat") - - message_content = File.read(File.join(template_dir, "message_model.rb.tt")) - expect(message_content).to include("acts_as_message") - - tool_call_content = File.read(File.join(template_dir, "tool_call_model.rb.tt")) - expect(tool_call_content).to include("acts_as_tool_call") + + it 'has proper acts_as_chat declaration in chat model' do + chat_content = File.read(File.join(template_dir, 'chat_model.rb.tt')) + expect(chat_content).to include('acts_as_chat') + end + + it 'has proper acts_as_message declaration in message model' do + message_content = File.read(File.join(template_dir, 'message_model.rb.tt')) + expect(message_content).to include('acts_as_message') + end + + it 'has proper acts_as_tool_call declaration in tool call model' do + tool_call_content = File.read(File.join(template_dir, 'tool_call_model.rb.tt')) + expect(tool_call_content).to include('acts_as_tool_call') end end - - describe "initializer template" do - it "has expected initializer template file" do - expect(File.exist?(File.join(template_dir, "initializer.rb.tt"))).to be(true) - end - - it "has proper configuration content" do - initializer_content = File.read(File.join(template_dir, "initializer.rb.tt")) - expect(initializer_content).to include("RubyLLM.configure do |config|") - expect(initializer_content).to include("config.openai_api_key") - expect(initializer_content).to include("ENV[\"OPENAI_API_KEY\"]") - expect(initializer_content).to include("config.anthropic_api_key") + + describe 'initializer template' do + it 'has expected initializer template file' do + expect(File.exist?(File.join(template_dir, 'initializer.rb.tt'))).to be(true) + end + + it 'includes RubyLLM configuration block' do + initializer_content = File.read(File.join(template_dir, 'initializer.rb.tt')) + expect(initializer_content).to include('RubyLLM.configure do |config|') + end + + it 'includes API key configuration options' do + initializer_content = File.read(File.join(template_dir, 'initializer.rb.tt')) + expect(initializer_content).to include('config.openai_api_key') + expect(initializer_content).to include('config.anthropic_api_key') end end - - describe "README template" do - it "has a README template file" do - expect(File.exist?(File.join(template_dir, "README.md"))).to be(true) - end - - it "has helpful post-installation instructions" do - readme_content = File.read(File.join(template_dir, "README.md")) - expect(readme_content).to include("RubyLLM Rails Setup Complete") - expect(readme_content).to include("Run migrations") - expect(readme_content).to include("rails db:migrate") - expect(readme_content).to include("Set your API keys") - expect(readme_content).to include("Start using RubyLLM in your code") - expect(readme_content).to include("For streaming responses") + + describe 'README template' do + it 'has a README template file' do + expect(File.exist?(File.join(template_dir, 'README.md'))).to be(true) + end + + it 'has a welcome message and setup information' do + readme_content = File.read(File.join(template_dir, 'README.md')) + expect(readme_content).to include('RubyLLM Rails Setup Complete') + expect(readme_content).to include('Run migrations') + end + + it 'includes database migration instructions' do + readme_content = File.read(File.join(template_dir, 'README.md')) + expect(readme_content).to include('rails db:migrate') + end + + it 'includes API configuration instructions' do + readme_content = File.read(File.join(template_dir, 'README.md')) + expect(readme_content).to include('Set your API keys') + end + + it 'includes basic usage examples' do + readme_content = File.read(File.join(template_dir, 'README.md')) + expect(readme_content).to include('Start using RubyLLM in your code') + expect(readme_content).to include('For streaming responses') end end - - describe "generator file structure" do - it "has proper directory structure" do - generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" + + describe 'generator file structure' do + it 'has a valid generator file' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' expect(File.exist?(generator_file)).to be(true) - + end + + it 'inherits from Rails::Generators::Base' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' + generator_content = File.read(generator_file) + expect(generator_content).to include('class InstallGenerator < Rails::Generators::Base') + end + + it 'includes Rails::Generators::Migration' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' generator_content = File.read(generator_file) - expect(generator_content).to include("class InstallGenerator < Rails::Generators::Base") - expect(generator_content).to include("include Rails::Generators::Migration") - expect(generator_content).to include("def create_migration_files") - expect(generator_content).to include("def create_model_files") - expect(generator_content).to include("def create_initializer") - expect(generator_content).to include("def show_readme") - end - - it "creates migrations in the correct order" do - generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" + expect(generator_content).to include('include Rails::Generators::Migration') + end + + it 'defines all required methods' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' + generator_content = File.read(generator_file) + expect(generator_content).to include('def create_migration_files') + expect(generator_content).to include('def create_model_files') + expect(generator_content).to include('def create_initializer') + expect(generator_content).to include('def show_readme') + end + + it 'has migrations in the correct order' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' generator_content = File.read(generator_file) - - # Check for correct order in migration creation - # 1. First chats table (no dependencies) - # 2. Then tool_calls table (will be referenced by messages) - # 3. Finally messages table (depends on both chats and tool_calls) - + # Simply check the order of template calls - # Chats should come before tool_calls, which should come before messages chats_position = generator_content.index('create_chats.rb') - tool_calls_position = generator_content.index('create_tool_calls.rb') + tool_calls_position = generator_content.index('create_tool_calls.rb') messages_position = generator_content.index('create_messages.rb') - + # Verify order: chats -> tool_calls -> messages expect(chats_position).to be < tool_calls_position expect(tool_calls_position).to be < messages_position - - # Also test that the method enforces sequential timestamps - expect(generator_content).to include("@migration_number = Time.now.utc.strftime") - expect(generator_content).to include("@migration_number = (@migration_number.to_i + 1).to_s") - expect(generator_content).to include("@migration_number = (@migration_number.to_i + 2).to_s") + end + + it 'enforces sequential migration timestamps' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' + generator_content = File.read(generator_file) + + expect(generator_content).to include('@migration_number = Time.now.utc.strftime') + expect(generator_content).to include('@migration_number = (@migration_number.to_i + 1).to_s') end end - - describe "database adapter detection" do - it "has proper postgresql detection method" do - generator_file = "/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb" + + describe 'database adapter detection' do + it 'defines a postgresql? method' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' + generator_content = File.read(generator_file) + expect(generator_content).to include('def postgresql?') + end + + it 'detects PostgreSQL by adapter name' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' + generator_content = File.read(generator_file) + expect(generator_content).to include('ActiveRecord::Base.connection.adapter_name.downcase.include?') + end + + it 'handles exceptions gracefully' do + generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' generator_content = File.read(generator_file) - - # Check proper postgresql? method implementation - expect(generator_content).to include("def postgresql?") - expect(generator_content).to include("ActiveRecord::Base.connection.adapter_name.downcase.include?(\"postgresql\")") - expect(generator_content).to include("rescue") - expect(generator_content).to include("false") + expect(generator_content).to include('rescue') + expect(generator_content).to include('false') end end -end \ No newline at end of file +end From 131e51caadd17018a5ef26e60da667293acc635c Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 10:03:28 -0700 Subject: [PATCH 09/30] chore: memory --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 30f75488..7002243c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ - Follow consistent model naming conventions across providers - Keep normalized model IDs (separate model from provider) - Use consistent parameter naming across providers +- Always run rubycop to lint ## Testing Practices - Tests automatically create VCR cassettes based on their descriptions From f40d1b4c6481b0c4b0779d504e96d3692ef94b40 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 27 Mar 2025 12:12:27 -0500 Subject: [PATCH 10/30] Delete CLAUDE.md --- CLAUDE.md | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7002243c..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,25 +0,0 @@ -# RubyLLM Development Guidelines - -## Commands -- Run all tests: `bundle exec rspec` -- Run single test: `bundle exec rspec spec/path/to/file_spec.rb` -- Run specific test: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` -- Code style check: `bundle exec rubocop` -- Auto-fix style issues: `bundle exec rubocop -A` -- Record VCR cassettes: `bundle exec rake vcr:record[provider]` (where provider is openai, anthropic, gemini, etc.) - -## Style Guidelines -- Follow Standard Ruby style (https://github.com/testdouble/standard) -- Use frozen_string_literal comment at top of files -- Add YARD documentation comments for public methods -- Use proper error handling with specific error classes -- Follow consistent model naming conventions across providers -- Keep normalized model IDs (separate model from provider) -- Use consistent parameter naming across providers -- Always run rubycop to lint - -## Testing Practices -- Tests automatically create VCR cassettes based on their descriptions -- Use specific, descriptive test descriptions -- Check VCR cassettes for sensitive information -- Write tests for all new features and bug fixes \ No newline at end of file From 5eeedf9a397a3604629e36c93e5fa7b285951d3c Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 28 Mar 2025 10:30:41 -0500 Subject: [PATCH 11/30] Update CONTRIBUTING.md --- CONTRIBUTING.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b6cb1fb..5cdfadc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,9 +133,6 @@ bundle exec rspec # Run a specific test file bundle exec rspec spec/ruby_llm/chat_spec.rb - -# Run generator template tests -bundle exec rspec spec/lib/generators/ruby_llm/template_files_spec.rb ``` ### Recording VCR Cassettes @@ -207,4 +204,4 @@ Gem versioning follows [Semantic Versioning](https://semver.org/): Releases are handled by the maintainers through the CI/CD pipeline. -Thanks for helping make RubyLLM better! \ No newline at end of file +Thanks for helping make RubyLLM better! From 9d8c2d6612e279d6e33cd22773b0ca570d938d51 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 28 Mar 2025 13:20:00 -0700 Subject: [PATCH 12/30] chore: clean up whitespace and add install generator tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed unnecessary whitespace in railtie.rb for cleaner code. - Added comprehensive tests for the RubyLLM::InstallGenerator to ensure migration and model templates are correctly defined and structured. - Deleted outdated template files spec to streamline test organization. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- lib/ruby_llm/railtie.rb | 4 +- .../ruby_llm/install_generator_spec.rb | 265 ++++++++++++++++++ .../ruby_llm/template_files_spec.rb | 198 ------------- 3 files changed, 267 insertions(+), 200 deletions(-) create mode 100644 spec/lib/generators/ruby_llm/install_generator_spec.rb delete mode 100644 spec/lib/generators/ruby_llm/template_files_spec.rb diff --git a/lib/ruby_llm/railtie.rb b/lib/ruby_llm/railtie.rb index ce5e5479..baaa5590 100644 --- a/lib/ruby_llm/railtie.rb +++ b/lib/ruby_llm/railtie.rb @@ -8,12 +8,12 @@ class Railtie < Rails::Railtie include RubyLLM::ActiveRecord::ActsAs end end - + # Include rake tasks if applicable rake_tasks do # Task definitions go here if needed end - + # Register generators generators do require 'generators/ruby_llm/install_generator' diff --git a/spec/lib/generators/ruby_llm/install_generator_spec.rb b/spec/lib/generators/ruby_llm/install_generator_spec.rb new file mode 100644 index 00000000..ff1560a5 --- /dev/null +++ b/spec/lib/generators/ruby_llm/install_generator_spec.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'generators/ruby_llm/install_generator' + +RSpec.describe RubyLLM::InstallGenerator, type: :generator do + # Use the actual template directory + let(:template_dir) { '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install/templates' } + let(:generator_file) { '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' } + + describe 'migration templates' do + let(:expected_migration_files) do + [ + 'create_chats_migration.rb.tt', + 'create_messages_migration.rb.tt', + 'create_tool_calls_migration.rb.tt' + ] + end + + it 'has all required migration template files' do + expected_migration_files.each do |file| + expect(File.exist?(File.join(template_dir, file))).to be(true) + end + end + + describe 'chats migration' do + let(:chat_migration) { File.read(File.join(template_dir, 'create_chats_migration.rb.tt')) } + + it 'defines chats table' do + expect(chat_migration).to include('create_table :chats') + end + + it 'includes model_id field' do + expect(chat_migration).to include('t.string :model_id') + end + end + + describe 'messages migration' do + let(:message_migration) { File.read(File.join(template_dir, 'create_messages_migration.rb.tt')) } + + it 'defines messages table' do + expect(message_migration).to include('create_table :messages') + end + + it 'includes chat reference' do + expect(message_migration).to include('t.references :chat') + end + + it 'includes role field' do + expect(message_migration).to include('t.string :role') + end + + it 'includes content field' do + expect(message_migration).to include('t.text :content') + end + end + + describe 'tool_calls migration' do + let(:tool_call_migration) { File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) } + + it 'defines tool_calls table' do + expect(tool_call_migration).to include('create_table :tool_calls') + end + + it 'includes tool_call_id field' do + expect(tool_call_migration).to include('t.string :tool_call_id') + end + + it 'includes name field' do + expect(tool_call_migration).to include('t.string :name') + end + end + end + + describe 'JSON handling in migrations' do + let(:tool_call_migration) { File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) } + + describe 'PostgreSQL support' do + it 'includes postgresql condition check' do + expect(tool_call_migration).to include('if postgresql?') + end + + it 'uses jsonb type' do + expect(tool_call_migration).to include('t.jsonb :arguments') + end + end + + describe 'other databases support' do + it 'includes else condition' do + expect(tool_call_migration).to include('else') + end + + it 'uses json type' do + expect(tool_call_migration).to include('t.json :arguments') + end + end + end + + describe 'model templates' do + let(:expected_model_files) do + [ + 'chat_model.rb.tt', + 'message_model.rb.tt', + 'tool_call_model.rb.tt' + ] + end + + it 'has all required model template files' do + expected_model_files.each do |file| + expect(File.exist?(File.join(template_dir, file))).to be(true) + end + end + + it 'declares acts_as_chat in chat model' do + chat_content = File.read(File.join(template_dir, 'chat_model.rb.tt')) + expect(chat_content).to include('acts_as_chat') + end + + it 'declares acts_as_message in message model' do + message_content = File.read(File.join(template_dir, 'message_model.rb.tt')) + expect(message_content).to include('acts_as_message') + end + + it 'declares acts_as_tool_call in tool call model' do + tool_call_content = File.read(File.join(template_dir, 'tool_call_model.rb.tt')) + expect(tool_call_content).to include('acts_as_tool_call') + end + end + + describe 'initializer template' do + let(:initializer_content) { File.read(File.join(template_dir, 'initializer.rb.tt')) } + + it 'has initializer template file' do + expect(File.exist?(File.join(template_dir, 'initializer.rb.tt'))).to be(true) + end + + it 'includes RubyLLM configuration block' do + expect(initializer_content).to include('RubyLLM.configure do |config|') + end + + it 'configures OpenAI API key' do + expect(initializer_content).to include('config.openai_api_key') + end + + it 'configures Anthropic API key' do + expect(initializer_content).to include('config.anthropic_api_key') + end + end + + describe 'README template' do + let(:readme_content) { File.read(File.join(template_dir, 'README.md')) } + + it 'has README template file' do + expect(File.exist?(File.join(template_dir, 'README.md'))).to be(true) + end + + it 'includes welcome message' do + expect(readme_content).to include('RubyLLM Rails Setup Complete') + end + + it 'includes setup information' do + expect(readme_content).to include('Run migrations') + end + + it 'includes migration instructions' do + expect(readme_content).to include('rails db:migrate') + end + + it 'includes API configuration instructions' do + expect(readme_content).to include('Set your API keys') + end + + it 'includes usage examples' do + expect(readme_content).to include('Start using RubyLLM in your code') + end + + it 'includes streaming response information' do + expect(readme_content).to include('For streaming responses') + end + end + + describe 'generator structure' do + let(:generator_content) { File.read(generator_file) } + + it 'has generator file' do + expect(File.exist?(generator_file)).to be(true) + end + + it 'inherits from Rails::Generators::Base' do + expect(generator_content).to include('class InstallGenerator < Rails::Generators::Base') + end + + it 'includes Rails::Generators::Migration' do + expect(generator_content).to include('include Rails::Generators::Migration') + end + end + + describe 'generator methods' do + let(:generator_content) { File.read(generator_file) } + + it 'defines create_migration_files method' do + expect(generator_content).to include('def create_migration_files') + end + + it 'defines create_model_files method' do + expect(generator_content).to include('def create_model_files') + end + + it 'defines create_initializer method' do + expect(generator_content).to include('def create_initializer') + end + + it 'defines show_readme method' do + expect(generator_content).to include('def show_readme') + end + end + + describe 'migration sequence' do + let(:generator_content) { File.read(generator_file) } + let(:migrations_order) do + { + chats: generator_content.index('create_chats.rb'), + tool_calls: generator_content.index('create_tool_calls.rb'), + messages: generator_content.index('create_messages.rb') + } + end + + it 'orders chats before tool_calls' do + expect(migrations_order[:chats]).to be < migrations_order[:tool_calls] + end + + it 'orders tool_calls before messages' do + expect(migrations_order[:tool_calls]).to be < migrations_order[:messages] + end + + it 'initializes timestamp from current time' do + expect(generator_content).to include('@migration_number = Time.now.utc.strftime') + end + + it 'increments timestamp for sequential migrations' do + expect(generator_content).to include('@migration_number = (@migration_number.to_i + 1).to_s') + end + end + + describe 'database detection' do + let(:generator_content) { File.read(generator_file) } + + it 'defines postgresql? method' do + expect(generator_content).to include('def postgresql?') + end + + it 'detects PostgreSQL adapter' do + expect(generator_content).to include('ActiveRecord::Base.connection.adapter_name.downcase.include?') + end + + it 'includes rescue block for error handling' do + expect(generator_content).to include('rescue') + end + + it 'returns false on error' do + expect(generator_content).to include('false') + end + end +end diff --git a/spec/lib/generators/ruby_llm/template_files_spec.rb b/spec/lib/generators/ruby_llm/template_files_spec.rb deleted file mode 100644 index 2c9c0ea9..00000000 --- a/spec/lib/generators/ruby_llm/template_files_spec.rb +++ /dev/null @@ -1,198 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'fileutils' -require 'generators/ruby_llm/install_generator' - -RSpec.describe RubyLLM::InstallGenerator, type: :generator do - # Use the actual template directory - let(:template_dir) { '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install/templates' } - - describe 'migration templates' do - it 'has expected migration template files' do - expected_files = [ - 'create_chats_migration.rb.tt', - 'create_messages_migration.rb.tt', - 'create_tool_calls_migration.rb.tt' - ] - - expected_files.each do |file| - expect(File.exist?(File.join(template_dir, file))).to be(true), "Expected template file #{file} to exist" - end - end - - it 'has proper chats migration content' do - chat_migration = File.read(File.join(template_dir, 'create_chats_migration.rb.tt')) - expect(chat_migration).to include('create_table :chats') - expect(chat_migration).to include('t.string :model_id') - end - - it 'has proper messages migration content' do - message_migration = File.read(File.join(template_dir, 'create_messages_migration.rb.tt')) - expect(message_migration).to include('create_table :messages') - expect(message_migration).to include('t.references :chat') - expect(message_migration).to include('t.string :role') - expect(message_migration).to include('t.text :content') - end - - it 'has proper tool_calls migration content' do - tool_call_migration = File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) - expect(tool_call_migration).to include('create_table :tool_calls') - expect(tool_call_migration).to include('t.string :tool_call_id') - expect(tool_call_migration).to include('t.string :name') - end - - it 'supports database-agnostic JSON handling in migrations' do - tool_call_migration = File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) - expect(tool_call_migration).to include('if postgresql?') - expect(tool_call_migration).to include('t.jsonb :arguments') - expect(tool_call_migration).to include('else') - expect(tool_call_migration).to include('t.json :arguments') - end - end - - describe 'model templates' do - it 'has expected model template files' do - expected_files = [ - 'chat_model.rb.tt', - 'message_model.rb.tt', - 'tool_call_model.rb.tt' - ] - - expected_files.each do |file| - expect(File.exist?(File.join(template_dir, file))).to be(true), "Expected template file #{file} to exist" - end - end - - it 'has proper acts_as_chat declaration in chat model' do - chat_content = File.read(File.join(template_dir, 'chat_model.rb.tt')) - expect(chat_content).to include('acts_as_chat') - end - - it 'has proper acts_as_message declaration in message model' do - message_content = File.read(File.join(template_dir, 'message_model.rb.tt')) - expect(message_content).to include('acts_as_message') - end - - it 'has proper acts_as_tool_call declaration in tool call model' do - tool_call_content = File.read(File.join(template_dir, 'tool_call_model.rb.tt')) - expect(tool_call_content).to include('acts_as_tool_call') - end - end - - describe 'initializer template' do - it 'has expected initializer template file' do - expect(File.exist?(File.join(template_dir, 'initializer.rb.tt'))).to be(true) - end - - it 'includes RubyLLM configuration block' do - initializer_content = File.read(File.join(template_dir, 'initializer.rb.tt')) - expect(initializer_content).to include('RubyLLM.configure do |config|') - end - - it 'includes API key configuration options' do - initializer_content = File.read(File.join(template_dir, 'initializer.rb.tt')) - expect(initializer_content).to include('config.openai_api_key') - expect(initializer_content).to include('config.anthropic_api_key') - end - end - - describe 'README template' do - it 'has a README template file' do - expect(File.exist?(File.join(template_dir, 'README.md'))).to be(true) - end - - it 'has a welcome message and setup information' do - readme_content = File.read(File.join(template_dir, 'README.md')) - expect(readme_content).to include('RubyLLM Rails Setup Complete') - expect(readme_content).to include('Run migrations') - end - - it 'includes database migration instructions' do - readme_content = File.read(File.join(template_dir, 'README.md')) - expect(readme_content).to include('rails db:migrate') - end - - it 'includes API configuration instructions' do - readme_content = File.read(File.join(template_dir, 'README.md')) - expect(readme_content).to include('Set your API keys') - end - - it 'includes basic usage examples' do - readme_content = File.read(File.join(template_dir, 'README.md')) - expect(readme_content).to include('Start using RubyLLM in your code') - expect(readme_content).to include('For streaming responses') - end - end - - describe 'generator file structure' do - it 'has a valid generator file' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - expect(File.exist?(generator_file)).to be(true) - end - - it 'inherits from Rails::Generators::Base' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - expect(generator_content).to include('class InstallGenerator < Rails::Generators::Base') - end - - it 'includes Rails::Generators::Migration' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - expect(generator_content).to include('include Rails::Generators::Migration') - end - - it 'defines all required methods' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - expect(generator_content).to include('def create_migration_files') - expect(generator_content).to include('def create_model_files') - expect(generator_content).to include('def create_initializer') - expect(generator_content).to include('def show_readme') - end - - it 'has migrations in the correct order' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - - # Simply check the order of template calls - chats_position = generator_content.index('create_chats.rb') - tool_calls_position = generator_content.index('create_tool_calls.rb') - messages_position = generator_content.index('create_messages.rb') - - # Verify order: chats -> tool_calls -> messages - expect(chats_position).to be < tool_calls_position - expect(tool_calls_position).to be < messages_position - end - - it 'enforces sequential migration timestamps' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - - expect(generator_content).to include('@migration_number = Time.now.utc.strftime') - expect(generator_content).to include('@migration_number = (@migration_number.to_i + 1).to_s') - end - end - - describe 'database adapter detection' do - it 'defines a postgresql? method' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - expect(generator_content).to include('def postgresql?') - end - - it 'detects PostgreSQL by adapter name' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - expect(generator_content).to include('ActiveRecord::Base.connection.adapter_name.downcase.include?') - end - - it 'handles exceptions gracefully' do - generator_file = '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' - generator_content = File.read(generator_file) - expect(generator_content).to include('rescue') - expect(generator_content).to include('false') - end - end -end From 61d66ab571638816cafe7db40787f2079af01f45 Mon Sep 17 00:00:00 2001 From: Jason Amster Date: Fri, 28 Mar 2025 10:51:26 -0400 Subject: [PATCH 13/30] first run at options for model names readme as template, migration # handled by rails, remove `frozen_literal` from models and migrations forcing timestamp order for migrations make act_as methods dynamic, cleaned ordering/references --- .../templates/{README.md => README.md.tt} | 21 ++++- .../install/templates/chat_model.rb.tt | 6 +- .../templates/create_chats_migration.rb.tt | 6 +- .../templates/create_messages_migration.rb.tt | 12 ++- .../create_tool_calls_migration.rb.tt | 20 ++--- .../install/templates/initializer.rb.tt | 2 - .../install/templates/message_model.rb.tt | 6 +- .../install/templates/tool_call_model.rb.tt | 6 +- lib/generators/ruby_llm/install_generator.rb | 79 +++++++++++++++---- lib/ruby_llm/active_record/acts_as.rb | 6 +- ruby_llm.gemspec | 6 ++ 11 files changed, 107 insertions(+), 63 deletions(-) rename lib/generators/ruby_llm/install/templates/{README.md => README.md.tt} (61%) diff --git a/lib/generators/ruby_llm/install/templates/README.md b/lib/generators/ruby_llm/install/templates/README.md.tt similarity index 61% rename from lib/generators/ruby_llm/install/templates/README.md rename to lib/generators/ruby_llm/install/templates/README.md.tt index fc4c1586..879f83da 100644 --- a/lib/generators/ruby_llm/install/templates/README.md +++ b/lib/generators/ruby_llm/install/templates/README.md.tt @@ -4,9 +4,22 @@ Thanks for installing RubyLLM in your Rails application. Here's what was created ## Models -- `Chat` - Stores chat sessions and their associated model ID -- `Message` - Stores individual messages in a chat -- `ToolCall` - Stores tool calls made by language models +- `<%= options[:chat_model_name] %>` - Stores chat sessions and their associated model ID +- `<%= options[:message_model_name] %>` - Stores individual messages in a chat +- `<%= options[:tool_call_model_name] %>` - Stores tool calls made by language models + +## Configuration Options + +The generator supports the following options to customize model names: + +```bash +rails generate ruby_llm:install \ + --chat-model-name=Conversation \ + --message-model-name=ChatMessage \ + --tool-call-model-name=FunctionCall +``` + +This is useful when you need to avoid namespace collisions with existing models in your application. Table names will be automatically derived from the model names following Rails conventions. ## Next Steps @@ -28,7 +41,7 @@ Thanks for installing RubyLLM in your Rails application. Here's what was created 3. **Start using RubyLLM in your code:** ```ruby # Create a new chat - chat = Chat.create!(model_id: "gpt-4o-mini") + chat = <%= options[:chat_model_name] %>.create!(model_id: "gpt-4o-mini") # Ask a question response = chat.ask("What's the best Ruby web framework?") diff --git a/lib/generators/ruby_llm/install/templates/chat_model.rb.tt b/lib/generators/ruby_llm/install/templates/chat_model.rb.tt index 90a4f03a..c8030bd0 100644 --- a/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +++ b/lib/generators/ruby_llm/install/templates/chat_model.rb.tt @@ -1,5 +1,3 @@ -# frozen_string_literal: true - -class Chat < ApplicationRecord - acts_as_chat +class <%= options[:chat_model_name] %> < ApplicationRecord + <%= acts_as_chat_declaration %> end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt b/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt index 6b28c111..a5f6329e 100644 --- a/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +++ b/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt @@ -1,8 +1,6 @@ -# frozen_string_literal: true - -class CreateChats < ActiveRecord::Migration<%= migration_version %> +class Create<%= options[:chat_model_name].pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :chats do |t| + create_table :<%= options[:chat_model_name].tableize %> do |t| t.string :model_id t.timestamps end diff --git a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt index a284bf0b..531afad1 100644 --- a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +++ b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt @@ -1,17 +1,15 @@ -# frozen_string_literal: true - -# This migration must be run AFTER create_chats and create_tool_calls migrations +# This migration must be run AFTER create_<%= options[:chat_model_name].tableize %> and create_<%= options[:tool_call_model_name].tableize %> migrations # to ensure proper foreign key references -class CreateMessages < ActiveRecord::Migration<%= migration_version %> +class Create<%= options[:message_model_name].pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :messages do |t| - t.references :chat, null: false, foreign_key: true + create_table :<%= options[:message_model_name].tableize %> do |t| + t.references :<%= options[:chat_model_name].tableize.singularize %>, null: false, foreign_key: true t.string :role t.text :content t.string :model_id t.integer :input_tokens t.integer :output_tokens - t.references :tool_call, foreign_key: true + t.references :<%= options[:tool_call_model_name].tableize.singularize %> t.timestamps end end diff --git a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt index d0234719..f3a3d200 100644 --- a/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +++ b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt @@ -1,22 +1,14 @@ -# frozen_string_literal: true - -# Migration for creating tool_calls table with database-specific JSON handling -class CreateToolCalls < ActiveRecord::Migration<%= migration_version %> +<%#- # Migration for creating tool_calls table with database-specific JSON handling -%> +class Create<%= options[:tool_call_model_name].pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :tool_calls do |t| + create_table :<%= options[:tool_call_model_name].tableize %> do |t| + t.references :<%= options[:message_model_name].tableize.singularize %>, null: false, foreign_key: true t.string :tool_call_id, null: false t.string :name, null: false - - # Use the appropriate JSON column type for the database - if postgresql? - t.jsonb :arguments, default: {} - else - t.json :arguments, default: {} - end - + t.<%= postgresql? ? 'jsonb' : 'json' %> :arguments, default: {} t.timestamps end - add_index :tool_calls, :tool_call_id + add_index :<%= options[:tool_call_model_name].tableize %>, :tool_call_id end end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/initializer.rb.tt b/lib/generators/ruby_llm/install/templates/initializer.rb.tt index 26f10e77..3fd92ff7 100644 --- a/lib/generators/ruby_llm/install/templates/initializer.rb.tt +++ b/lib/generators/ruby_llm/install/templates/initializer.rb.tt @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # RubyLLM configuration RubyLLM.configure do |config| # Set your API keys here or use environment variables diff --git a/lib/generators/ruby_llm/install/templates/message_model.rb.tt b/lib/generators/ruby_llm/install/templates/message_model.rb.tt index fc3bcbba..2875c2be 100644 --- a/lib/generators/ruby_llm/install/templates/message_model.rb.tt +++ b/lib/generators/ruby_llm/install/templates/message_model.rb.tt @@ -1,5 +1,3 @@ -# frozen_string_literal: true - -class Message < ApplicationRecord - acts_as_message +class <%= options[:message_model_name] %> < ApplicationRecord + <%= acts_as_message_declaration %> end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt b/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt index 693b98c8..11df7429 100644 --- a/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +++ b/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt @@ -1,5 +1,3 @@ -# frozen_string_literal: true - -class ToolCall < ApplicationRecord - acts_as_tool_call +class <%= options[:tool_call_model_name] %> < ApplicationRecord + <%= acts_as_tool_call_declaration %> end \ No newline at end of file diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 5c291e72..4be6d85b 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -7,13 +7,21 @@ module RubyLLM # Generator for RubyLLM Rails models and migrations class InstallGenerator < Rails::Generators::Base include Rails::Generators::Migration + namespace "ruby_llm:install" source_root File.expand_path('install/templates', __dir__) + class_option :chat_model_name, type: :string, default: 'Chat', + desc: 'Name of the Chat model class' + class_option :message_model_name, type: :string, default: 'Message', + desc: 'Name of the Message model class' + class_option :tool_call_model_name, type: :string, default: 'ToolCall', + desc: 'Name of the ToolCall model class' + desc 'Creates model files for Chat, Message, and ToolCall, and creates migrations for RubyLLM Rails integration' def self.next_migration_number(dirname) - ActiveRecord::Generators::Base.next_migration_number(dirname) + ::ActiveRecord::Generators::Base.next_migration_number(dirname) end def migration_version @@ -26,30 +34,66 @@ def postgresql? false end - def create_migration_files - # Create migrations in the correct order with sequential timestamps - # to ensure proper foreign key references: - # 1. First create chats (no dependencies) - # 2. Then create tool_calls (will be referenced by messages) - # 3. Finally create messages (depends on both chats and tool_calls) + def acts_as_chat_declaration + acts_as_chat_params = [] + if options[:message_model_name] + acts_as_chat_params << "message_class: \"#{options[:message_model_name]}\"" + end + if options[:tool_call_model_name] + acts_as_chat_params << "tool_call_class: \"#{options[:tool_call_model_name]}\"" + end + if acts_as_chat_params.any? + "acts_as_chat #{acts_as_chat_params.join(', ')}" + else + "acts_as_chat" + end + end + + def acts_as_message_declaration + acts_as_message_params = [] + if options[:chat_model_name] + acts_as_message_params << "chat_class: \"#{options[:chat_model_name]}\"" + end + if options[:tool_call_model_name] + acts_as_message_params << "tool_call_class: \"#{options[:tool_call_model_name]}\"" + end + if acts_as_message_params.any? + "acts_as_message #{acts_as_message_params.join(', ')}" + else + "acts_as_message" + end + end + + def acts_as_tool_call_declaration + acts_as_tool_call_params = [] + if options[:message_model_name] + acts_as_tool_call_params << "message_class: \"#{options[:message_model_name]}\"" + end + if acts_as_tool_call_params.any? + "acts_as_tool_call #{acts_as_tool_call_params.join(', ')}" + else + "acts_as_tool_call" + end + end + def create_migration_files # Use a fixed timestamp for testing and to ensure they're sequential - @migration_number = Time.now.utc.strftime('%Y%m%d%H%M%S') - migration_template 'create_chats_migration.rb.tt', 'db/migrate/create_chats.rb' + # @migration_number = Time.now.utc.strftime('%Y%m%d%H%M%S') + migration_template 'create_chats_migration.rb.tt', "db/migrate/create_#{options[:chat_model_name].tableize}.rb" # Increment timestamp for the next migration - @migration_number = (@migration_number.to_i + 1).to_s - migration_template 'create_tool_calls_migration.rb.tt', 'db/migrate/create_tool_calls.rb' + # @migration_number = (@migration_number.to_i + 1).to_s + migration_template 'create_messages_migration.rb.tt', "db/migrate/create_#{options[:message_model_name].tableize}.rb" # Increment timestamp again for the final migration - @migration_number = (@migration_number.to_i + 2).to_s - migration_template 'create_messages_migration.rb.tt', 'db/migrate/create_messages.rb' + # @migration_number = (@migration_number.to_i + 2).to_s + migration_template 'create_tool_calls_migration.rb.tt', "db/migrate/create_#{options[:tool_call_model_name].tableize}.rb" end def create_model_files - template 'chat_model.rb.tt', 'app/models/chat.rb' - template 'message_model.rb.tt', 'app/models/message.rb' - template 'tool_call_model.rb.tt', 'app/models/tool_call.rb' + template 'chat_model.rb.tt', "app/models/#{options[:chat_model_name].underscore}.rb" + template 'message_model.rb.tt', "app/models/#{options[:message_model_name].underscore}.rb" + template 'tool_call_model.rb.tt', "app/models/#{options[:tool_call_model_name].underscore}.rb" end def create_initializer @@ -57,7 +101,8 @@ def create_initializer end def show_readme - readme 'README.md' + content = ERB.new(File.read(source_paths.first + '/README.md.tt')).result(binding) + say content end end end diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 366e7cf1..51812753 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -37,7 +37,7 @@ def acts_as_message(chat_class: 'Chat', tool_call_class: 'ToolCall') # rubocop:d @chat_class = chat_class.to_s @tool_call_class = tool_call_class.to_s - belongs_to :chat, class_name: @chat_class + belongs_to :chat, class_name: @chat_class, foreign_key: "#{@chat_class.underscore}_id" has_many :tool_calls, class_name: @tool_call_class, dependent: :destroy belongs_to :parent_tool_call, @@ -52,7 +52,7 @@ def acts_as_message(chat_class: 'Chat', tool_call_class: 'ToolCall') # rubocop:d def acts_as_tool_call(message_class: 'Message') @message_class = message_class.to_s - belongs_to :message, class_name: @message_class + belongs_to :message, class_name: @message_class, foreign_key: "#{@message_class.underscore}_id" has_one :result, class_name: @message_class, @@ -170,4 +170,4 @@ def extract_content end end end -end +end \ No newline at end of file diff --git a/ruby_llm.gemspec b/ruby_llm.gemspec index e64acdcc..a5216009 100644 --- a/ruby_llm.gemspec +++ b/ruby_llm.gemspec @@ -34,6 +34,9 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + # Add generator files + spec.files += Dir.glob('lib/generators/**/*') + # Runtime dependencies spec.add_dependency 'base64' spec.add_dependency 'event_stream_parser', '~> 1' @@ -41,4 +44,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'faraday-multipart', '~> 1' spec.add_dependency 'faraday-retry', '~> 2' spec.add_dependency 'zeitwerk', '~> 2' + + # Development dependencies + spec.add_development_dependency 'rails', '>= 7.0.0' end From a239b34f02c8a8e34a8b8072c875d6b7e024a780 Mon Sep 17 00:00:00 2001 From: Jason Amster Date: Mon, 31 Mar 2025 11:54:45 -0400 Subject: [PATCH 14/30] fixing generator specs with updated `options` setup --- .../ruby_llm/install_generator_spec.rb | 59 +++---------------- 1 file changed, 9 insertions(+), 50 deletions(-) diff --git a/spec/lib/generators/ruby_llm/install_generator_spec.rb b/spec/lib/generators/ruby_llm/install_generator_spec.rb index ff1560a5..57e4a70d 100644 --- a/spec/lib/generators/ruby_llm/install_generator_spec.rb +++ b/spec/lib/generators/ruby_llm/install_generator_spec.rb @@ -6,8 +6,8 @@ RSpec.describe RubyLLM::InstallGenerator, type: :generator do # Use the actual template directory - let(:template_dir) { '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install/templates' } - let(:generator_file) { '/Users/kieranklaassen/rails/ruby_llm/lib/generators/ruby_llm/install_generator.rb' } + let(:template_dir) { File.join(File.dirname(__FILE__), '../../../../lib/generators/ruby_llm/install/templates') } + let(:generator_file) { File.join(File.dirname(__FILE__), '../../../../lib/generators/ruby_llm/install_generator.rb') } describe 'migration templates' do let(:expected_migration_files) do @@ -28,7 +28,7 @@ let(:chat_migration) { File.read(File.join(template_dir, 'create_chats_migration.rb.tt')) } it 'defines chats table' do - expect(chat_migration).to include('create_table :chats') + expect(chat_migration).to include('create_table :<%= options[:chat_model_name].tableize %>') end it 'includes model_id field' do @@ -40,11 +40,11 @@ let(:message_migration) { File.read(File.join(template_dir, 'create_messages_migration.rb.tt')) } it 'defines messages table' do - expect(message_migration).to include('create_table :messages') + expect(message_migration).to include('create_table :<%= options[:message_model_name].tableize %>') end it 'includes chat reference' do - expect(message_migration).to include('t.references :chat') + expect(message_migration).to include('t.references :<%= options[:chat_model_name].tableize.singularize %>, null: false, foreign_key: true') end it 'includes role field' do @@ -60,7 +60,7 @@ let(:tool_call_migration) { File.read(File.join(template_dir, 'create_tool_calls_migration.rb.tt')) } it 'defines tool_calls table' do - expect(tool_call_migration).to include('create_table :tool_calls') + expect(tool_call_migration).to include('create_table :<%= options[:tool_call_model_name].tableize %>') end it 'includes tool_call_id field' do @@ -78,21 +78,7 @@ describe 'PostgreSQL support' do it 'includes postgresql condition check' do - expect(tool_call_migration).to include('if postgresql?') - end - - it 'uses jsonb type' do - expect(tool_call_migration).to include('t.jsonb :arguments') - end - end - - describe 'other databases support' do - it 'includes else condition' do - expect(tool_call_migration).to include('else') - end - - it 'uses json type' do - expect(tool_call_migration).to include('t.json :arguments') + expect(tool_call_migration).to include("t.<%= postgresql? ? 'jsonb' : 'json' %> :arguments, default: {}") end end end @@ -149,10 +135,10 @@ end describe 'README template' do - let(:readme_content) { File.read(File.join(template_dir, 'README.md')) } + let(:readme_content) { File.read(File.join(template_dir, 'README.md.tt')) } it 'has README template file' do - expect(File.exist?(File.join(template_dir, 'README.md'))).to be(true) + expect(File.exist?(File.join(template_dir, 'README.md.tt'))).to be(true) end it 'includes welcome message' do @@ -216,33 +202,6 @@ end end - describe 'migration sequence' do - let(:generator_content) { File.read(generator_file) } - let(:migrations_order) do - { - chats: generator_content.index('create_chats.rb'), - tool_calls: generator_content.index('create_tool_calls.rb'), - messages: generator_content.index('create_messages.rb') - } - end - - it 'orders chats before tool_calls' do - expect(migrations_order[:chats]).to be < migrations_order[:tool_calls] - end - - it 'orders tool_calls before messages' do - expect(migrations_order[:tool_calls]).to be < migrations_order[:messages] - end - - it 'initializes timestamp from current time' do - expect(generator_content).to include('@migration_number = Time.now.utc.strftime') - end - - it 'increments timestamp for sequential migrations' do - expect(generator_content).to include('@migration_number = (@migration_number.to_i + 1).to_s') - end - end - describe 'database detection' do let(:generator_content) { File.read(generator_file) } From cc3d23fbc75ed88a3654e00d411069bb19b14d17 Mon Sep 17 00:00:00 2001 From: Jason Amster Date: Tue, 8 Apr 2025 12:12:54 -0400 Subject: [PATCH 15/30] Addresses comments in PR Address PR feedback and cleanup - Replace File.dirname(__FILE__) with __dir__ in specs for better Ruby idioms - Remove redundant spec.files line for generators (already covered by lib/**/*) - Move rails development dependency to Gemfile from gemspec - Update example in README to use ActiveJob instead of controller for better practices Note on migration timestamps: The current implementation has no table dependencies, so migration order is not critical. Rails generators automatically handle timestamps for migrations, making manual timestamp management unnecessary. --- Gemfile | 1 + .../ruby_llm/install/templates/README.md.tt | 21 ++++++---- lib/generators/ruby_llm/install_generator.rb | 42 +++++++------------ ruby_llm.gemspec | 6 --- .../ruby_llm/install_generator_spec.rb | 4 +- 5 files changed, 32 insertions(+), 42 deletions(-) diff --git a/Gemfile b/Gemfile index 95d97963..01b1ebb7 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ group :development do # Rails integration dependencies gem 'activerecord', '>= 6.0', '< 9.0' gem 'activesupport', '>= 6.0', '< 9.0' + gem 'rails', '>= 7.0.0' # Development dependencies gem 'bundler', '>= 2.0' diff --git a/lib/generators/ruby_llm/install/templates/README.md.tt b/lib/generators/ruby_llm/install/templates/README.md.tt index 879f83da..6b529abb 100644 --- a/lib/generators/ruby_llm/install/templates/README.md.tt +++ b/lib/generators/ruby_llm/install/templates/README.md.tt @@ -40,14 +40,21 @@ This is useful when you need to avoid namespace collisions with existing models 3. **Start using RubyLLM in your code:** ```ruby - # Create a new chat + # In a background job + class ChatJob < ApplicationJob + def perform(chat_id, question) + chat = <%= options[:chat_model_name] %>.find(chat_id) + chat.ask(question) do |chunk| + Turbo::StreamsChannel.broadcast_append_to( + chat, target: "response", partial: "messages/chunk", locals: { chunk: chunk } + ) + end + end + end + + # Queue the job chat = <%= options[:chat_model_name] %>.create!(model_id: "gpt-4o-mini") - - # Ask a question - response = chat.ask("What's the best Ruby web framework?") - - # Get chat history - chat.messages + ChatJob.perform_later(chat.id, "What's your favorite Ruby gem?") ``` 4. **For streaming responses** with ActionCable or Turbo: diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 4be6d85b..519aa6ca 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -7,16 +7,16 @@ module RubyLLM # Generator for RubyLLM Rails models and migrations class InstallGenerator < Rails::Generators::Base include Rails::Generators::Migration - namespace "ruby_llm:install" + namespace 'ruby_llm:install' source_root File.expand_path('install/templates', __dir__) class_option :chat_model_name, type: :string, default: 'Chat', - desc: 'Name of the Chat model class' + desc: 'Name of the Chat model class' class_option :message_model_name, type: :string, default: 'Message', - desc: 'Name of the Message model class' + desc: 'Name of the Message model class' class_option :tool_call_model_name, type: :string, default: 'ToolCall', - desc: 'Name of the ToolCall model class' + desc: 'Name of the ToolCall model class' desc 'Creates model files for Chat, Message, and ToolCall, and creates migrations for RubyLLM Rails integration' @@ -36,58 +36,46 @@ def postgresql? def acts_as_chat_declaration acts_as_chat_params = [] - if options[:message_model_name] - acts_as_chat_params << "message_class: \"#{options[:message_model_name]}\"" - end - if options[:tool_call_model_name] - acts_as_chat_params << "tool_call_class: \"#{options[:tool_call_model_name]}\"" - end + acts_as_chat_params << "message_class: \"#{options[:message_model_name]}\"" if options[:message_model_name] + acts_as_chat_params << "tool_call_class: \"#{options[:tool_call_model_name]}\"" if options[:tool_call_model_name] if acts_as_chat_params.any? "acts_as_chat #{acts_as_chat_params.join(', ')}" else - "acts_as_chat" + 'acts_as_chat' end end def acts_as_message_declaration acts_as_message_params = [] - if options[:chat_model_name] - acts_as_message_params << "chat_class: \"#{options[:chat_model_name]}\"" - end + acts_as_message_params << "chat_class: \"#{options[:chat_model_name]}\"" if options[:chat_model_name] if options[:tool_call_model_name] acts_as_message_params << "tool_call_class: \"#{options[:tool_call_model_name]}\"" end if acts_as_message_params.any? "acts_as_message #{acts_as_message_params.join(', ')}" else - "acts_as_message" + 'acts_as_message' end end def acts_as_tool_call_declaration acts_as_tool_call_params = [] - if options[:message_model_name] - acts_as_tool_call_params << "message_class: \"#{options[:message_model_name]}\"" - end + acts_as_tool_call_params << "message_class: \"#{options[:message_model_name]}\"" if options[:message_model_name] if acts_as_tool_call_params.any? "acts_as_tool_call #{acts_as_tool_call_params.join(', ')}" else - "acts_as_tool_call" + 'acts_as_tool_call' end end def create_migration_files - # Use a fixed timestamp for testing and to ensure they're sequential - # @migration_number = Time.now.utc.strftime('%Y%m%d%H%M%S') migration_template 'create_chats_migration.rb.tt', "db/migrate/create_#{options[:chat_model_name].tableize}.rb" - # Increment timestamp for the next migration - # @migration_number = (@migration_number.to_i + 1).to_s - migration_template 'create_messages_migration.rb.tt', "db/migrate/create_#{options[:message_model_name].tableize}.rb" + migration_template 'create_messages_migration.rb.tt', + "db/migrate/create_#{options[:message_model_name].tableize}.rb" - # Increment timestamp again for the final migration - # @migration_number = (@migration_number.to_i + 2).to_s - migration_template 'create_tool_calls_migration.rb.tt', "db/migrate/create_#{options[:tool_call_model_name].tableize}.rb" + migration_template 'create_tool_calls_migration.rb.tt', + "db/migrate/create_#{options[:tool_call_model_name].tableize}.rb" end def create_model_files diff --git a/ruby_llm.gemspec b/ruby_llm.gemspec index 932b4e47..f7a1f891 100644 --- a/ruby_llm.gemspec +++ b/ruby_llm.gemspec @@ -29,9 +29,6 @@ Gem::Specification.new do |spec| spec.files = Dir.glob('lib/**/*') + ['README.md', 'LICENSE'] spec.require_paths = ['lib'] - # Add generator files - spec.files += Dir.glob('lib/generators/**/*') - # Runtime dependencies spec.add_dependency 'base64' spec.add_dependency 'event_stream_parser', '~> 1' @@ -39,7 +36,4 @@ Gem::Specification.new do |spec| spec.add_dependency 'faraday-multipart', '~> 1' spec.add_dependency 'faraday-retry', '~> 2' spec.add_dependency 'zeitwerk', '~> 2' - - # Development dependencies - spec.add_development_dependency 'rails', '>= 7.0.0' end diff --git a/spec/lib/generators/ruby_llm/install_generator_spec.rb b/spec/lib/generators/ruby_llm/install_generator_spec.rb index 57e4a70d..f094feb8 100644 --- a/spec/lib/generators/ruby_llm/install_generator_spec.rb +++ b/spec/lib/generators/ruby_llm/install_generator_spec.rb @@ -6,8 +6,8 @@ RSpec.describe RubyLLM::InstallGenerator, type: :generator do # Use the actual template directory - let(:template_dir) { File.join(File.dirname(__FILE__), '../../../../lib/generators/ruby_llm/install/templates') } - let(:generator_file) { File.join(File.dirname(__FILE__), '../../../../lib/generators/ruby_llm/install_generator.rb') } + let(:template_dir) { File.join(__dir__, '../../../../lib/generators/ruby_llm/install/templates') } + let(:generator_file) { File.join(__dir__, '../../../../lib/generators/ruby_llm/install_generator.rb') } describe 'migration templates' do let(:expected_migration_files) do From fda4df15bf418cfb57406312b4771a8def6b20c7 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 18 Apr 2025 14:33:24 -0700 Subject: [PATCH 16/30] docs --- docs/guides/rails.md | 227 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 182 insertions(+), 45 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index 3c5d9667..7ffc0468 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -1,68 +1,48 @@ - - -⸻ - +--- layout: default title: Rails Integration parent: Guides nav_order: 5 permalink: /guides/rails +--- -Rails Integration - +# Rails Integration {: .no_toc } RubyLLM offers seamless integration with Ruby on Rails applications through helpers for ActiveRecord models. This allows you to easily persist chat conversations, including messages and tool interactions, directly in your database. {: .fs-6 .fw-300 } -Table of contents - +## Table of contents {: .no_toc .text-delta } - 1. TOC + +1. TOC {:toc} -⸻ +--- After reading this guide, you will know: - • How to set up ActiveRecord models for persisting chats and messages. - • How to use acts_as_chat and acts_as_message. - • How chat interactions automatically persist data. - • A basic approach for integrating streaming responses with Hotwire/Turbo Streams. - -Setup -Using the Generator (Recommended) +* How to set up ActiveRecord models for persisting chats and messages. +* How to use `acts_as_chat` and `acts_as_message`. +* How chat interactions automatically persist data. +* A basic approach for integrating streaming responses with Hotwire/Turbo Streams. -The easiest way to set up RubyLLM with Rails is to use the built‑in generator: +## Setup -rails generate ruby_llm:install +### Create Migrations -This will automatically: - 1. Create the necessary migrations for chats, messages, and tool calls. - 2. Create model files with appropriate acts_as_* methods. - 3. Set up proper relationships between models. - -After running the generator, simply run the migrations: - -rails db:migrate - -Manual Setup - -If you prefer to set up manually or need to customize the implementation, follow these steps. - -1. Create Migrations - -First, generate migrations for your Chat and Message models. You’ll also need a ToolCall model if you plan to use [Tools]({% link guides/tools.md %}). +First, generate migrations for your `Chat` and `Message` models. You'll also need a `ToolCall` model if you plan to use [Tools]({% link guides/tools.md %}). +```bash # Generate basic models and migrations -rails g model Chat model_id:string user:references # Example user association +rails g model Chat model_id:string user:references # Example user association rails g model Message chat:references role:string content:text model_id:string input_tokens:integer output_tokens:integer tool_call:references rails g model ToolCall message:references tool_call_id:string:index name:string arguments:jsonb +``` -Adjust the migrations as needed (e.g., null: false constraints, and jsonb type for PostgreSQL). - -Then complete the migration files: +Adjust the migrations as needed (e.g., `null: false` constraints, `jsonb` type for PostgreSQL). +```ruby # db/migrate/YYYYMMDDHHMMSS_create_chats.rb class CreateChats < ActiveRecord::Migration[7.1] def change @@ -104,15 +84,15 @@ class CreateToolCalls < ActiveRecord::Migration[7.1] end end end +``` -Run the migrations: +Run the migrations: `rails db:migrate` -rails db:migrate +### Set Up Models with `acts_as` Helpers -2. Set Up Models - -Create the model classes and include the RubyLLM helpers in your ActiveRecord models: +Include the RubyLLM helpers in your ActiveRecord models. +```ruby # app/models/chat.rb class Chat < ApplicationRecord # Includes methods like ask, with_tool, with_instructions, etc. @@ -127,4 +107,161 @@ end # app/models/message.rb class Message < ApplicationRecord # Provides methods like tool_call?, tool_result? - acts_as_message # Ass \ No newline at end of file + acts_as_message # Assumes Chat and ToolCall model names + + # --- Add your standard Rails model logic below --- +end + +# app/models/tool_call.rb (Only if using tools) +class ToolCall < ApplicationRecord + # Sets up associations to the calling message and the result message. + acts_as_tool_call # Assumes Message model name + + # --- Add your standard Rails model logic below --- +end +``` + +{: .note } +The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. + +### Configure RubyLLM + +Ensure your RubyLLM configuration (API keys, etc.) is set up, typically in `config/initializers/ruby_llm.rb`. See the [Installation Guide]({% link installation.md %}) for details. + +## Basic Usage + +The `acts_as_chat` helper delegates common `RubyLLM::Chat` methods to your `Chat` model. When you call these methods on an ActiveRecord `Chat` instance, RubyLLM automatically handles persistence. + +```ruby +# Create a new chat record +chat_record = Chat.create!(model_id: 'gpt-4.1-nano', user: current_user) + +# The `model_id` should typically be a valid identifier known to RubyLLM. +# See the [Working with Models Guide]({% link guides/models.md %}) for details. + +# Ask a question. This automatically: +# 1. Saves the user message ("What is the capital...") +# 2. Makes the API call with history +# 3. Saves the assistant message (the response) +response = chat_record.ask "What is the capital of France?" + +# `response` is the RubyLLM::Message object from the API call. +# The persisted record is associated with `chat_record`. +assistant_message_record = chat_record.messages.last +puts assistant_message_record.content # => "The capital of France is Paris." + +# Continue the conversation +chat_record.ask "Tell me more about that city" + +# Verify persistence +puts "Conversation length: #{chat_record.messages.count}" # => 4 +``` + +## Persisting Instructions + +Instructions (system prompts) set via `with_instructions` are also automatically persisted as `Message` records with the `system` role. + +```ruby +chat_record = Chat.create!(model_id: 'gpt-4.1-nano') + +# This creates and saves a Message record with role: :system +chat_record.with_instructions("You are a Ruby expert.") + +# This replaces the previous system message in the database +chat_record.with_instructions("You are a concise Ruby expert.", replace: true) + +system_message = chat_record.messages.find_by(role: :system) +puts system_message.content # => "You are a concise Ruby expert." +``` + +## Streaming Responses with Hotwire/Turbo + +You can combine `acts_as_chat` with streaming and Turbo Streams for real-time UI updates. The persistence logic works seamlessly alongside the streaming block. + +Here's a simplified approach using a background job: + +```ruby +# app/models/chat.rb +class Chat < ApplicationRecord + acts_as_chat + belongs_to :user, optional: true + # Broadcast message creations to the chat channel + broadcasts_to ->(chat) { [chat, "messages"] } +end + +# app/models/message.rb +class Message < ApplicationRecord + acts_as_message + # Broadcast updates to self (for streaming into the message frame) + broadcasts_to ->(message) { [message.chat, "messages"] } + + # Helper to broadcast chunks during streaming + def broadcast_append_chunk(chunk_content) + broadcast_append_to [ chat, "messages" ], # Target the stream + target: dom_id(self, "content"), # Target the content div inside the message frame + html: chunk_content # Append the raw chunk + end +end + +# app/jobs/chat_stream_job.rb +class ChatStreamJob < ApplicationJob + queue_as :default + + def perform(chat_id, user_content) + chat = Chat.find(chat_id) + # The `ask` method automatically saves the user message first. + # It then creates the assistant message record *before* streaming starts, + # and updates it with the final content/tokens upon completion. + chat.ask(user_content) do |chunk| + # Get the latest (assistant) message record, which was created by `ask` + assistant_message = chat.messages.last + if chunk.content && assistant_message + # Append the chunk content to the message's target div + assistant_message.broadcast_append_chunk(chunk.content) + end + end + # Final assistant message is now fully persisted by acts_as_chat + end +end +``` + +```erb +<%# app/views/chats/show.html.erb %> +<%= turbo_stream_from [@chat, "messages"] %> +

Chat <%= @chat.id %>

+
+ <%= render @chat.messages %> +
+ +<%= form_with(url: chat_messages_path(@chat), method: :post) do |f| %> + <%= f.text_area :content %> + <%= f.submit "Send" %> +<% end %> + +<%# app/views/messages/_message.html.erb %> +<%= turbo_frame_tag message do %> +
+ <%= message.role.capitalize %>: + <%# Target div for streaming content %> +
" style="display: inline;"> + <%# Render initial content if not streaming, otherwise job appends here %> + <%= simple_format(message.content) %> +
+
+<% end %> +``` + +{: .note } +This example shows the core idea. You'll need to adapt the broadcasting, targets, and partials for your specific UI needs (e.g., handling Markdown rendering, adding styling, showing typing indicators). See the [Streaming Responses Guide]({% link guides/streaming.md %}) for more on streaming itself. + +## Customizing Models + +Your `Chat`, `Message`, and `ToolCall` models are standard ActiveRecord models. You can add any other associations, validations, scopes, callbacks, or methods as needed for your application logic. The `acts_as` helpers provide the core persistence bridge to RubyLLM without interfering with other model behavior. + +## Next Steps + +* [Chatting with AI Models]({% link guides/chat.md %}) +* [Using Tools]({% link guides/tools.md %}) +* [Streaming Responses]({% link guides/streaming.md %}) +* [Working with Models]({% link guides/models.md %}) +* [Error Handling]({% link guides/error-handling.md %}) \ No newline at end of file From 6270f22f276ecaf9d93122c7004b1b1f6b0f7b1c Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 18 Apr 2025 14:37:13 -0700 Subject: [PATCH 17/30] docs: Update Rails integration guide to include generator usage and improve formatting - Added a section on using the generator for setting up RubyLLM in Rails applications. - Improved formatting for better readability, including table of contents and notes. - Updated examples to reflect changes in model creation and button usage in forms. --- docs/guides/rails.md | 124 +++++++++++++++++++++++++++---------------- 1 file changed, 79 insertions(+), 45 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index 7ffc0468..070cb5a1 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -7,31 +7,67 @@ permalink: /guides/rails --- # Rails Integration + {: .no_toc } -RubyLLM offers seamless integration with Ruby on Rails applications through helpers for ActiveRecord models. This allows you to easily persist chat conversations, including messages and tool interactions, directly in your database. -{: .fs-6 .fw-300 } +RubyLLM offers seamless integration with Ruby on Rails applications through helpers for ActiveRecord models. This allows you to easily persist chat conversations, including messages and tool interactions, directly in your database. {: .fs-6 .fw-300 } ## Table of contents + {: .no_toc .text-delta } -1. TOC -{:toc} +1. TOC {:toc} --- After reading this guide, you will know: -* How to set up ActiveRecord models for persisting chats and messages. -* How to use `acts_as_chat` and `acts_as_message`. -* How chat interactions automatically persist data. -* A basic approach for integrating streaming responses with Hotwire/Turbo Streams. +- How to set up ActiveRecord models for persisting chats and messages. +- How to use `acts_as_chat` and `acts_as_message`. +- How chat interactions automatically persist data. +- A basic approach for integrating streaming responses with Hotwire/Turbo Streams. ## Setup -### Create Migrations +### Using the Generator + +The simplest way to set up RubyLLM in your Rails application is to use the provided generator: + +```bash +# Generate all necessary models, migrations, and configuration +rails generate ruby_llm:install +``` + +This will create: + +- A `Chat` model for storing chat sessions +- A `Message` model for storing individual messages +- A `ToolCall` model for storing tool calls +- Migrations for all these models +- A RubyLLM initializer + +If you need to customize model names to avoid namespace collisions, you can provide options: + +```bash +rails generate ruby_llm:install \ + --chat-model-name=Conversation \ + --message-model-name=ChatMessage \ + --tool-call-model-name=FunctionCall +``` + +After running the generator, simply run the migrations: + +```bash +rails db:migrate +``` + +### Manual Setup (Alternative) -First, generate migrations for your `Chat` and `Message` models. You'll also need a `ToolCall` model if you plan to use [Tools]({% link guides/tools.md %}). +If you prefer to set up the models manually, follow these steps: + +#### Create Migrations + +Generate migrations for your `Chat` and `Message` models. You'll also need a `ToolCall` model if you plan to use [Tools]({% link guides/tools.md %}). ```bash # Generate basic models and migrations @@ -88,7 +124,7 @@ end Run the migrations: `rails db:migrate` -### Set Up Models with `acts_as` Helpers +#### Set Up Models with `acts_as` Helpers Include the RubyLLM helpers in your ActiveRecord models. @@ -121,20 +157,28 @@ class ToolCall < ApplicationRecord end ``` -{: .note } -The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. +{: .note } The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. ### Configure RubyLLM Ensure your RubyLLM configuration (API keys, etc.) is set up, typically in `config/initializers/ruby_llm.rb`. See the [Installation Guide]({% link installation.md %}) for details. +```ruby +# config/initializers/ruby_llm.rb +RubyLLM.configure do |config| + config.openai_api_key = ENV["OPENAI_API_KEY"] + config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] + # Add other provider keys as needed +end +``` + ## Basic Usage The `acts_as_chat` helper delegates common `RubyLLM::Chat` methods to your `Chat` model. When you call these methods on an ActiveRecord `Chat` instance, RubyLLM automatically handles persistence. ```ruby # Create a new chat record -chat_record = Chat.create!(model_id: 'gpt-4.1-nano', user: current_user) +chat_record = Chat.create!(model_id: 'gpt-4o-mini') # The `model_id` should typically be a valid identifier known to RubyLLM. # See the [Working with Models Guide]({% link guides/models.md %}) for details. @@ -162,7 +206,7 @@ puts "Conversation length: #{chat_record.messages.count}" # => 4 Instructions (system prompts) set via `with_instructions` are also automatically persisted as `Message` records with the `system` role. ```ruby -chat_record = Chat.create!(model_id: 'gpt-4.1-nano') +chat_record = Chat.create!(model_id: 'gpt-4o-mini') # This creates and saves a Message record with role: :system chat_record.with_instructions("You are a Ruby expert.") @@ -181,6 +225,18 @@ You can combine `acts_as_chat` with streaming and Turbo Streams for real-time UI Here's a simplified approach using a background job: ```ruby +# app/jobs/chat_job.rb +class ChatJob < ApplicationJob + def perform(chat_id, question) + chat = Chat.find(chat_id) + chat.ask(question) do |chunk| + Turbo::StreamsChannel.broadcast_append_to( + chat, target: "response", partial: "messages/chunk", locals: { chunk: chunk } + ) + end + end +end + # app/models/chat.rb class Chat < ApplicationRecord acts_as_chat @@ -202,27 +258,6 @@ class Message < ApplicationRecord html: chunk_content # Append the raw chunk end end - -# app/jobs/chat_stream_job.rb -class ChatStreamJob < ApplicationJob - queue_as :default - - def perform(chat_id, user_content) - chat = Chat.find(chat_id) - # The `ask` method automatically saves the user message first. - # It then creates the assistant message record *before* streaming starts, - # and updates it with the final content/tokens upon completion. - chat.ask(user_content) do |chunk| - # Get the latest (assistant) message record, which was created by `ask` - assistant_message = chat.messages.last - if chunk.content && assistant_message - # Append the chunk content to the message's target div - assistant_message.broadcast_append_chunk(chunk.content) - end - end - # Final assistant message is now fully persisted by acts_as_chat - end -end ``` ```erb @@ -235,7 +270,7 @@ end <%= form_with(url: chat_messages_path(@chat), method: :post) do |f| %> <%= f.text_area :content %> - <%= f.submit "Send" %> + <%= f.button "Send", disable_with: "Sending..." %> <% end %> <%# app/views/messages/_message.html.erb %> @@ -243,7 +278,7 @@ end
<%= message.role.capitalize %>: <%# Target div for streaming content %> -
" style="display: inline;"> +
" class="message-content"> <%# Render initial content if not streaming, otherwise job appends here %> <%= simple_format(message.content) %>
@@ -251,8 +286,7 @@ end <% end %> ``` -{: .note } -This example shows the core idea. You'll need to adapt the broadcasting, targets, and partials for your specific UI needs (e.g., handling Markdown rendering, adding styling, showing typing indicators). See the [Streaming Responses Guide]({% link guides/streaming.md %}) for more on streaming itself. +{: .note } This example shows the core idea. You'll need to adapt the broadcasting, targets, and partials for your specific UI needs (e.g., handling Markdown rendering, adding styling, showing typing indicators). See the [Streaming Responses Guide]({% link guides/streaming.md %}) for more on streaming itself. ## Customizing Models @@ -260,8 +294,8 @@ Your `Chat`, `Message`, and `ToolCall` models are standard ActiveRecord models. ## Next Steps -* [Chatting with AI Models]({% link guides/chat.md %}) -* [Using Tools]({% link guides/tools.md %}) -* [Streaming Responses]({% link guides/streaming.md %}) -* [Working with Models]({% link guides/models.md %}) -* [Error Handling]({% link guides/error-handling.md %}) \ No newline at end of file +- [Chatting with AI Models]({% link guides/chat.md %}) +- [Using Tools]({% link guides/tools.md %}) +- [Streaming Responses]({% link guides/streaming.md %}) +- [Working with Models]({% link guides/models.md %}) +- [Error Handling]({% link guides/error-handling.md %}) From f5454aad6a423193ca8b6def3336b5edbbf36b7b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 18 Apr 2025 14:38:13 -0700 Subject: [PATCH 18/30] docs: Update Rails integration guide with user association in chat record creation - Modified examples in the Rails integration guide to reflect the inclusion of the `user` parameter when creating a new chat record. - Updated model_id in examples to 'gpt-4.1-nano' for consistency. --- docs/guides/rails.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index 070cb5a1..b67527b2 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -178,7 +178,7 @@ The `acts_as_chat` helper delegates common `RubyLLM::Chat` methods to your `Chat ```ruby # Create a new chat record -chat_record = Chat.create!(model_id: 'gpt-4o-mini') +chat_record = Chat.create!(model_id: 'gpt-4.1-nano', user: current_user) # The `model_id` should typically be a valid identifier known to RubyLLM. # See the [Working with Models Guide]({% link guides/models.md %}) for details. @@ -206,7 +206,7 @@ puts "Conversation length: #{chat_record.messages.count}" # => 4 Instructions (system prompts) set via `with_instructions` are also automatically persisted as `Message` records with the `system` role. ```ruby -chat_record = Chat.create!(model_id: 'gpt-4o-mini') +chat_record = Chat.create!(model_id: 'gpt-4.1-nano') # This creates and saves a Message record with role: :system chat_record.with_instructions("You are a Ruby expert.") From 5be36c15818939ceb88081552e279daca65cbdc6 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 18 Apr 2025 14:39:12 -0700 Subject: [PATCH 19/30] docs: Add ChatStreamJob implementation to Rails integration guide - Introduced the `ChatStreamJob` class to the Rails integration guide, detailing its purpose and functionality in handling chat streaming. - Included comments within the job to explain the process of saving user messages and broadcasting assistant message updates. --- docs/guides/rails.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index b67527b2..d872a66f 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -258,6 +258,27 @@ class Message < ApplicationRecord html: chunk_content # Append the raw chunk end end + +# app/jobs/chat_stream_job.rb +class ChatStreamJob < ApplicationJob + queue_as :default + + def perform(chat_id, user_content) + chat = Chat.find(chat_id) + # The `ask` method automatically saves the user message first. + # It then creates the assistant message record *before* streaming starts, + # and updates it with the final content/tokens upon completion. + chat.ask(user_content) do |chunk| + # Get the latest (assistant) message record, which was created by `ask` + assistant_message = chat.messages.last + if chunk.content && assistant_message + # Append the chunk content to the message's target div + assistant_message.broadcast_append_chunk(chunk.content) + end + end + # Final assistant message is now fully persisted by acts_as_chat + end +end ``` ```erb From 1ed3c2e76133ce949a58a0a7fecfe8b85cc206be Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 15:52:03 -0700 Subject: [PATCH 20/30] Address PR comments: rename README.md.tt, fix migration order, update railtie.rb, and revert rails.md changes --- .cursor/rules/add_new_provider.mdc | 69 +++++++++++++++++ .cursor/rules/ruby_llm_conventions.mdc | 45 +++++++++++ .cursor/rules/ruby_llm_philosophy.mdc | 15 ++++ .cursor/rules/ruby_style_guide.mdc | 74 +++++++++++++++++++ docs/guides/rails.md | 22 ++---- .../{README.md.tt => INSTALL_INFO.md.tt} | 0 .../templates/create_messages_migration.rb.tt | 5 +- lib/generators/ruby_llm/install_generator.rb | 27 +++++-- lib/ruby_llm/railtie.rb | 5 +- 9 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 .cursor/rules/add_new_provider.mdc create mode 100644 .cursor/rules/ruby_llm_conventions.mdc create mode 100644 .cursor/rules/ruby_llm_philosophy.mdc create mode 100644 .cursor/rules/ruby_style_guide.mdc rename lib/generators/ruby_llm/install/templates/{README.md.tt => INSTALL_INFO.md.tt} (100%) diff --git a/.cursor/rules/add_new_provider.mdc b/.cursor/rules/add_new_provider.mdc new file mode 100644 index 00000000..34fb1607 --- /dev/null +++ b/.cursor/rules/add_new_provider.mdc @@ -0,0 +1,69 @@ +--- +description: Add a new provider to the `ruby_llm` gem. +globs: +alwaysApply: false +--- + +# Add New Provider Guide + +**Description:** This document outlines the standard procedure to follow whenever adding a new provider (e.g., "NewAPI") to the `ruby_llm` gem. It uses the structure of existing providers like OpenAI as a template. + +## Step-by-Step Instructions + +1. **Create Directory Structure:** + + - Provider code: `lib/ruby_llm/providers/new_api/` + - Tests: `spec/ruby_llm/providers/new_api/` + - VCR cassettes: `spec/fixtures/vcr_cassettes/new_api/` + +2. **Implement Provider Client (`RubyLLM::Providers::NewAPI::Client`):** + + - Create `[lib/ruby_llm/providers/new_api/client.rb](mdc:lib/ruby_llm/providers/new_api/client.rb)`. + - Handle initialization (API keys via ENV or `RubyLLM.configuration`, Faraday connection setup). + - Implement core methods (`chat`, `embeddings`, etc.) matching the standard interface. + - Translate generic arguments to the provider-specific API format. + - Make HTTP requests using Faraday. + - Handle HTTP and API-specific errors. + - Instantiate and return a `RubyLLM::Providers::NewAPI::Response`. + - Add YARD documentation. + +3. **Implement Provider Response (`RubyLLM::Providers::NewAPI::Response`):** + + - Create `[lib/ruby_llm/providers/new_api/response.rb](mdc:lib/ruby_llm/providers/new_api/response.rb)`. + - Inherit from `[RubyLLM::Response](mdc:lib/ruby_llm/response.rb)`. + - Override accessor methods (`model`, `content`, `tool_calls`, `finish_reason`, token counts, etc.) to extract data from the provider's specific response structure. + - Add YARD documentation. + +4. **Register the Provider:** + + - Edit `[lib/ruby_llm/client.rb](mdc:lib/ruby_llm/client.rb)`. + - Modify the provider selection logic (e.g., `case` statement in `initialize`) to instantiate `RubyLLM::Providers::NewAPI::Client` when `provider: :new_api` is specified. + +5. **Update Model Information:** + + - **Models (`[data/models.json](mdc:data/models.json)`):** Preferably update the `rake models:update` task in the `[Rakefile](mdc:Rakefile)` to fetch models from the new provider's API. Avoid manual edits if possible. + - **Aliases (`[data/aliases.json](mdc:data/aliases.json)`):** Add any necessary model aliases. + +6. **Add Tests:** + + - Create spec files in `spec/ruby_llm/providers/new_api/` (e.g., `client_spec.rb`, `response_spec.rb`). + - Test client initialization, successful API calls, response parsing, parameter handling, and error handling. + - Configure VCR in `[spec/spec_helper.rb](mdc:spec/spec_helper.rb)` to handle the new API domain and filter sensitive data (API keys). + +7. **Record VCR Cassettes:** + + - Set the provider-specific API key environment variable (e.g., `NEW_API_KEY`). + - Consider adding a Rake task in the `[Rakefile](mdc:Rakefile)` (e.g., `vcr:record_new_api`) to facilitate recording. + - Run the recording task. + - Run tests to ensure they use the cassettes. + - **Crucially:** Inspect the generated `.yml` cassettes in `spec/fixtures/vcr_cassettes/new_api/` for correctness and ensure no sensitive data remains. + +8. **Documentation:** + + - Add YARD comments to new code. + - Update `[README.md](mdc:README.md)` to include the new provider and API key instructions. + - Update guides in `docs/guides/` if necessary. + +9. **Style and Committing:** + - Run `bundle exec rubocop -A` to enforce style defined in `[.rubocop.yml](mdc:.rubocop.yml)`. Overcommit hooks (`[.overcommit.yml](mdc:.overcommit.yml)`) should also run checks. + - Use conventional commit messages (e.g., `feat: add support for NewAPI provider`). diff --git a/.cursor/rules/ruby_llm_conventions.mdc b/.cursor/rules/ruby_llm_conventions.mdc new file mode 100644 index 00000000..9704aff5 --- /dev/null +++ b/.cursor/rules/ruby_llm_conventions.mdc @@ -0,0 +1,45 @@ +--- +description: +globs: +alwaysApply: true +--- + +# RubyLLM Project Conventions + +This document outlines key conventions and practices for developing the `ruby_llm` project. + +## Overview + +`ruby_llm` is a Ruby gem providing a unified interface for various AI LLMs (OpenAI, Anthropic, Gemini, Bedrock, DeepSeek). See the [README.md](mdc:README.md) for a full feature list and basic usage examples. + +## Dependencies & Setup + +- Dependencies are managed via [Gemfile](mdc:Gemfile) and installed using `bundle install`. +- API keys are configured via environment variables (see [README.md](mdc:README.md)). +- Development setup involves cloning, `bundle install`, and setting up git hooks with `overcommit --install`. See [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for details. + +## Testing + +- Tests are run using `bundle exec rspec`. +- HTTP interactions are mocked using VCR. Cassettes are stored in `spec/fixtures/vcr_cassettes/`. +- Re-record VCR cassettes using `bundle exec rake vcr:record[provider_name]` or `bundle exec rake vcr:record[all]` after setting necessary API keys as environment variables. Review new cassettes carefully. +- Refer to [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for the full testing workflow. + +## Coding Style & Conventions + +- **Style:** Adhere strictly to [Standard Ruby](https://github.com/testdouble/standard). Use `bundle exec rubocop` to check and `bundle exec rubocop -A` to fix. Git hooks (`overcommit`) enforce style. +- **Model Naming:** Use normalized model IDs (e.g., `claude-3-5-sonnet`) and specify the provider separately (e.g., `provider: :bedrock`). See [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for details on provider implementation and `aliases.json`. Do not edit `models.json` manually; use `rake models:update`. +- **Documentation:** Use YARD for inline comments. Update guides in `docs/guides/`. +- **Git:** Use feature branches, preferably linked to GitHub issues. Follow conventional commit messages. See [CONTRIBUTING.md](mdc:CONTRIBUTING.md). + +## Key Files + +- [README.md](mdc:README.md): Project overview, installation, basic usage. +- [CONTRIBUTING.md](mdc:CONTRIBUTING.md): Detailed contribution guidelines, development setup, testing, conventions. +- [Gemfile](mdc:Gemfile): Project dependencies. +- [.rubocop.yml](mdc:.rubocop.yml), [.overcommit.yml](mdc:.overcommit.yml): Style and git hook configuration. +- [.rspec](mdc:.rspec): RSpec configuration. +- [Rakefile](mdc:Rakefile): Defines Rake tasks, including VCR management. +- `lib/`: Main source code directory. +- `spec/`: Test files directory. +- `docs/`: Documentation guides. diff --git a/.cursor/rules/ruby_llm_philosophy.mdc b/.cursor/rules/ruby_llm_philosophy.mdc new file mode 100644 index 00000000..87baf4d8 --- /dev/null +++ b/.cursor/rules/ruby_llm_philosophy.mdc @@ -0,0 +1,15 @@ +--- +description: +globs: +alwaysApply: false +--- + +# Ruby LLM Project Philosophy & Tone + +When working on the `ruby_llm` project (`crmne/ruby_llm`), embody the following principles: + +- **Developer-Centric & Joyful:** Prioritize developer experience (DX). Aim for code and APIs that are "delightful" and a "joy to use," removing unnecessary complexity ("No configuration madness, no complex callbacks, no handler hell"). +- **Expressive & Idiomatic Ruby:** Write "beautiful, expressive Ruby code." Follow idiomatic Ruby patterns and conventions. The goal is for developers to feel like they are writing natural Ruby. +- **Pragmatic & Unified:** Focus on solving real developer problems by providing a clean, unified interface ("One beautiful API for everything") that abstracts away inconsistencies between different AI providers. +- **Simple & Minimalist:** Favor simplicity and minimal dependencies. Keep the core lightweight. Code examples should be clear and concise. +- **Tone:** Enthusiastic, positive, problem/solution-oriented, concise, and slightly contrarian (positioning against unnecessary complexity). Align with Ruby community values of developer happiness and "taste." diff --git a/.cursor/rules/ruby_style_guide.mdc b/.cursor/rules/ruby_style_guide.mdc new file mode 100644 index 00000000..dc681629 --- /dev/null +++ b/.cursor/rules/ruby_style_guide.mdc @@ -0,0 +1,74 @@ +--- +description: +globs: + - "*.rb" +alwaysApply: false +--- + +# Ruby Style Guide and Conventions + +This document outlines the key Ruby style and architectural patterns observed in the project. Adhering to these conventions ensures consistency. + +**Overall Style:** + +- Follows **Standard Ruby** style guidelines. +- Likely enforced by **RuboCop**; respect existing `# rubocop:disable` directives but avoid adding new ones without justification. +- Prioritize clean, readable, and consistently formatted code. + +**Specific Conventions:** + +1. **File Structure:** + + - Start files with `# frozen_string_literal: true`. + - Use nested `module` definitions for namespacing (e.g., `module MyModule module NestedModule`). Do _not_ use `Module::Class` style for definitions. + +2. **Module Design:** + + - For utility modules containing stateless functions, use `module_function` at the start to export methods as module methods. + +3. **Naming:** + + - **Modules/Classes:** `PascalCase` + - **Methods/Variables:** `snake_case` + - **Predicate Methods:** End with `?` (e.g., `active?`) + - **Constants:** `SCREAMING_SNAKE_CASE` + - **Unused Parameters:** Prefix with `_` (e.g., `_unused_param`). + +4. **Methods:** + + - Use parentheses for definitions with parameters: `def my_method(param1, param2)`. + - Parentheses are optional for calls with no arguments or where unambiguous. + - Employ guard clauses (`return value if condition`) for early exits. + - Rely on implicit returns. + +5. **Documentation:** + + - Use **YARD** format for documenting methods (`@param`, `@return`). + +6. **Control Flow:** + + - Prefer `case/when` for multiple conditions on a single value, often using regex (`/pattern/`). + - Use `if/unless` for simpler conditions. + +7. **Data Structures:** + + - Use symbols (`:key`) for Hash keys. + - Use `Hash#dig` for safe access to nested hash values. + - Use `||` for default values. + - Use trailing commas in multi-line Hashes and Arrays. + +8. **Strings & Regex:** + + - Prefer single quotes (`'`) for strings without interpolation. + - Use literal regex syntax (`/.../`) and prefer `match?` for boolean checks. + +9. **Formatting:** + - 2-space indentation. + - Whitespace around operators and after commas. + - Break and indent chained method calls for readability. + +**Architecture:** + +- Organize code into clearly namespaced modules and classes. +- Create utility modules for shared, stateless logic (using `module_function`). +- Centralize configuration (e.g., in constants like `PRICES` in the example). diff --git a/docs/guides/rails.md b/docs/guides/rails.md index d872a66f..275380c0 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -10,13 +10,15 @@ permalink: /guides/rails {: .no_toc } -RubyLLM offers seamless integration with Ruby on Rails applications through helpers for ActiveRecord models. This allows you to easily persist chat conversations, including messages and tool interactions, directly in your database. {: .fs-6 .fw-300 } +RubyLLM offers seamless integration with Ruby on Rails applications through helpers for ActiveRecord models. This allows you to easily persist chat conversations, including messages and tool interactions, directly in your database. +{: .fs-6 .fw-300 } ## Table of contents {: .no_toc .text-delta } -1. TOC {:toc} +1. TOC +{:toc} --- @@ -25,7 +27,6 @@ After reading this guide, you will know: - How to set up ActiveRecord models for persisting chats and messages. - How to use `acts_as_chat` and `acts_as_message`. - How chat interactions automatically persist data. -- A basic approach for integrating streaming responses with Hotwire/Turbo Streams. ## Setup @@ -159,18 +160,7 @@ end {: .note } The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. -### Configure RubyLLM -Ensure your RubyLLM configuration (API keys, etc.) is set up, typically in `config/initializers/ruby_llm.rb`. See the [Installation Guide]({% link installation.md %}) for details. - -```ruby -# config/initializers/ruby_llm.rb -RubyLLM.configure do |config| - config.openai_api_key = ENV["OPENAI_API_KEY"] - config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] - # Add other provider keys as needed -end -``` ## Basic Usage @@ -307,7 +297,7 @@ end <% end %> ``` -{: .note } This example shows the core idea. You'll need to adapt the broadcasting, targets, and partials for your specific UI needs (e.g., handling Markdown rendering, adding styling, showing typing indicators). See the [Streaming Responses Guide]({% link guides/streaming.md %}) for more on streaming itself. +{: .note } This example shows the core idea. You'll need to adapt the broadcasting, targets, and partials for your specific UI needs (e.g., handling Markdown rendering, adding styling, showing typing indicators). ## Customizing Models @@ -317,6 +307,4 @@ Your `Chat`, `Message`, and `ToolCall` models are standard ActiveRecord models. - [Chatting with AI Models]({% link guides/chat.md %}) - [Using Tools]({% link guides/tools.md %}) -- [Streaming Responses]({% link guides/streaming.md %}) - [Working with Models]({% link guides/models.md %}) -- [Error Handling]({% link guides/error-handling.md %}) diff --git a/lib/generators/ruby_llm/install/templates/README.md.tt b/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt similarity index 100% rename from lib/generators/ruby_llm/install/templates/README.md.tt rename to lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt diff --git a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt index 531afad1..b036cac0 100644 --- a/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +++ b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt @@ -1,5 +1,4 @@ -# This migration must be run AFTER create_<%= options[:chat_model_name].tableize %> and create_<%= options[:tool_call_model_name].tableize %> migrations -# to ensure proper foreign key references +# Migration for creating messages table with references to chats and tool_calls class Create<%= options[:message_model_name].pluralize %> < ActiveRecord::Migration<%= migration_version %> def change create_table :<%= options[:message_model_name].tableize %> do |t| @@ -13,4 +12,4 @@ class Create<%= options[:message_model_name].pluralize %> < ActiveRecord::Migrat t.timestamps end end -end \ No newline at end of file +end diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 519aa6ca..9fe34d25 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -69,13 +69,24 @@ def acts_as_tool_call_declaration end def create_migration_files - migration_template 'create_chats_migration.rb.tt', "db/migrate/create_#{options[:chat_model_name].tableize}.rb" - - migration_template 'create_messages_migration.rb.tt', - "db/migrate/create_#{options[:message_model_name].tableize}.rb" - + # Create migrations with timestamps to ensure proper order + # First create chats table + migration_template 'create_chats_migration.rb.tt', + "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_#{options[:chat_model_name].tableize}.rb" + + # Wait 1 second to ensure different timestamp + sleep 1 + + # Then create tool_calls table migration_template 'create_tool_calls_migration.rb.tt', - "db/migrate/create_#{options[:tool_call_model_name].tableize}.rb" + "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_#{options[:tool_call_model_name].tableize}.rb" + + # Wait 1 second to ensure different timestamp + sleep 1 + + # Finally create messages table which references both previous tables + migration_template 'create_messages_migration.rb.tt', + "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_#{options[:message_model_name].tableize}.rb" end def create_model_files @@ -88,8 +99,8 @@ def create_initializer template 'initializer.rb.tt', 'config/initializers/ruby_llm.rb' end - def show_readme - content = ERB.new(File.read(source_paths.first + '/README.md.tt')).result(binding) + def show_install_info + content = ERB.new(File.read(source_paths.first + '/INSTALL_INFO.md.tt')).result(binding) say content end end diff --git a/lib/ruby_llm/railtie.rb b/lib/ruby_llm/railtie.rb index baaa5590..ba7323b7 100644 --- a/lib/ruby_llm/railtie.rb +++ b/lib/ruby_llm/railtie.rb @@ -9,9 +9,10 @@ class Railtie < Rails::Railtie end end - # Include rake tasks if applicable + # Include rake tasks rake_tasks do - # Task definitions go here if needed + path = File.expand_path(__dir__) + Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f } end # Register generators From f16858a5716d4cfac203e7698806666619a07bab Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 17:53:29 -0500 Subject: [PATCH 21/30] Delete .cursor/rules/add_new_provider.mdc --- .cursor/rules/add_new_provider.mdc | 69 ------------------------------ 1 file changed, 69 deletions(-) delete mode 100644 .cursor/rules/add_new_provider.mdc diff --git a/.cursor/rules/add_new_provider.mdc b/.cursor/rules/add_new_provider.mdc deleted file mode 100644 index 34fb1607..00000000 --- a/.cursor/rules/add_new_provider.mdc +++ /dev/null @@ -1,69 +0,0 @@ ---- -description: Add a new provider to the `ruby_llm` gem. -globs: -alwaysApply: false ---- - -# Add New Provider Guide - -**Description:** This document outlines the standard procedure to follow whenever adding a new provider (e.g., "NewAPI") to the `ruby_llm` gem. It uses the structure of existing providers like OpenAI as a template. - -## Step-by-Step Instructions - -1. **Create Directory Structure:** - - - Provider code: `lib/ruby_llm/providers/new_api/` - - Tests: `spec/ruby_llm/providers/new_api/` - - VCR cassettes: `spec/fixtures/vcr_cassettes/new_api/` - -2. **Implement Provider Client (`RubyLLM::Providers::NewAPI::Client`):** - - - Create `[lib/ruby_llm/providers/new_api/client.rb](mdc:lib/ruby_llm/providers/new_api/client.rb)`. - - Handle initialization (API keys via ENV or `RubyLLM.configuration`, Faraday connection setup). - - Implement core methods (`chat`, `embeddings`, etc.) matching the standard interface. - - Translate generic arguments to the provider-specific API format. - - Make HTTP requests using Faraday. - - Handle HTTP and API-specific errors. - - Instantiate and return a `RubyLLM::Providers::NewAPI::Response`. - - Add YARD documentation. - -3. **Implement Provider Response (`RubyLLM::Providers::NewAPI::Response`):** - - - Create `[lib/ruby_llm/providers/new_api/response.rb](mdc:lib/ruby_llm/providers/new_api/response.rb)`. - - Inherit from `[RubyLLM::Response](mdc:lib/ruby_llm/response.rb)`. - - Override accessor methods (`model`, `content`, `tool_calls`, `finish_reason`, token counts, etc.) to extract data from the provider's specific response structure. - - Add YARD documentation. - -4. **Register the Provider:** - - - Edit `[lib/ruby_llm/client.rb](mdc:lib/ruby_llm/client.rb)`. - - Modify the provider selection logic (e.g., `case` statement in `initialize`) to instantiate `RubyLLM::Providers::NewAPI::Client` when `provider: :new_api` is specified. - -5. **Update Model Information:** - - - **Models (`[data/models.json](mdc:data/models.json)`):** Preferably update the `rake models:update` task in the `[Rakefile](mdc:Rakefile)` to fetch models from the new provider's API. Avoid manual edits if possible. - - **Aliases (`[data/aliases.json](mdc:data/aliases.json)`):** Add any necessary model aliases. - -6. **Add Tests:** - - - Create spec files in `spec/ruby_llm/providers/new_api/` (e.g., `client_spec.rb`, `response_spec.rb`). - - Test client initialization, successful API calls, response parsing, parameter handling, and error handling. - - Configure VCR in `[spec/spec_helper.rb](mdc:spec/spec_helper.rb)` to handle the new API domain and filter sensitive data (API keys). - -7. **Record VCR Cassettes:** - - - Set the provider-specific API key environment variable (e.g., `NEW_API_KEY`). - - Consider adding a Rake task in the `[Rakefile](mdc:Rakefile)` (e.g., `vcr:record_new_api`) to facilitate recording. - - Run the recording task. - - Run tests to ensure they use the cassettes. - - **Crucially:** Inspect the generated `.yml` cassettes in `spec/fixtures/vcr_cassettes/new_api/` for correctness and ensure no sensitive data remains. - -8. **Documentation:** - - - Add YARD comments to new code. - - Update `[README.md](mdc:README.md)` to include the new provider and API key instructions. - - Update guides in `docs/guides/` if necessary. - -9. **Style and Committing:** - - Run `bundle exec rubocop -A` to enforce style defined in `[.rubocop.yml](mdc:.rubocop.yml)`. Overcommit hooks (`[.overcommit.yml](mdc:.overcommit.yml)`) should also run checks. - - Use conventional commit messages (e.g., `feat: add support for NewAPI provider`). From 26bcf500481cc1559c28acbfa67db98f18772958 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 17:53:55 -0500 Subject: [PATCH 22/30] Delete .cursor/rules/ruby_llm_conventions.mdc --- .cursor/rules/ruby_llm_conventions.mdc | 45 -------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .cursor/rules/ruby_llm_conventions.mdc diff --git a/.cursor/rules/ruby_llm_conventions.mdc b/.cursor/rules/ruby_llm_conventions.mdc deleted file mode 100644 index 9704aff5..00000000 --- a/.cursor/rules/ruby_llm_conventions.mdc +++ /dev/null @@ -1,45 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- - -# RubyLLM Project Conventions - -This document outlines key conventions and practices for developing the `ruby_llm` project. - -## Overview - -`ruby_llm` is a Ruby gem providing a unified interface for various AI LLMs (OpenAI, Anthropic, Gemini, Bedrock, DeepSeek). See the [README.md](mdc:README.md) for a full feature list and basic usage examples. - -## Dependencies & Setup - -- Dependencies are managed via [Gemfile](mdc:Gemfile) and installed using `bundle install`. -- API keys are configured via environment variables (see [README.md](mdc:README.md)). -- Development setup involves cloning, `bundle install`, and setting up git hooks with `overcommit --install`. See [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for details. - -## Testing - -- Tests are run using `bundle exec rspec`. -- HTTP interactions are mocked using VCR. Cassettes are stored in `spec/fixtures/vcr_cassettes/`. -- Re-record VCR cassettes using `bundle exec rake vcr:record[provider_name]` or `bundle exec rake vcr:record[all]` after setting necessary API keys as environment variables. Review new cassettes carefully. -- Refer to [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for the full testing workflow. - -## Coding Style & Conventions - -- **Style:** Adhere strictly to [Standard Ruby](https://github.com/testdouble/standard). Use `bundle exec rubocop` to check and `bundle exec rubocop -A` to fix. Git hooks (`overcommit`) enforce style. -- **Model Naming:** Use normalized model IDs (e.g., `claude-3-5-sonnet`) and specify the provider separately (e.g., `provider: :bedrock`). See [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for details on provider implementation and `aliases.json`. Do not edit `models.json` manually; use `rake models:update`. -- **Documentation:** Use YARD for inline comments. Update guides in `docs/guides/`. -- **Git:** Use feature branches, preferably linked to GitHub issues. Follow conventional commit messages. See [CONTRIBUTING.md](mdc:CONTRIBUTING.md). - -## Key Files - -- [README.md](mdc:README.md): Project overview, installation, basic usage. -- [CONTRIBUTING.md](mdc:CONTRIBUTING.md): Detailed contribution guidelines, development setup, testing, conventions. -- [Gemfile](mdc:Gemfile): Project dependencies. -- [.rubocop.yml](mdc:.rubocop.yml), [.overcommit.yml](mdc:.overcommit.yml): Style and git hook configuration. -- [.rspec](mdc:.rspec): RSpec configuration. -- [Rakefile](mdc:Rakefile): Defines Rake tasks, including VCR management. -- `lib/`: Main source code directory. -- `spec/`: Test files directory. -- `docs/`: Documentation guides. From c352d5f102a62519aaf798785acf36c7bb6deddf Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 17:54:07 -0500 Subject: [PATCH 23/30] Delete .cursor/rules/ruby_style_guide.mdc --- .cursor/rules/ruby_style_guide.mdc | 74 ------------------------------ 1 file changed, 74 deletions(-) delete mode 100644 .cursor/rules/ruby_style_guide.mdc diff --git a/.cursor/rules/ruby_style_guide.mdc b/.cursor/rules/ruby_style_guide.mdc deleted file mode 100644 index dc681629..00000000 --- a/.cursor/rules/ruby_style_guide.mdc +++ /dev/null @@ -1,74 +0,0 @@ ---- -description: -globs: - - "*.rb" -alwaysApply: false ---- - -# Ruby Style Guide and Conventions - -This document outlines the key Ruby style and architectural patterns observed in the project. Adhering to these conventions ensures consistency. - -**Overall Style:** - -- Follows **Standard Ruby** style guidelines. -- Likely enforced by **RuboCop**; respect existing `# rubocop:disable` directives but avoid adding new ones without justification. -- Prioritize clean, readable, and consistently formatted code. - -**Specific Conventions:** - -1. **File Structure:** - - - Start files with `# frozen_string_literal: true`. - - Use nested `module` definitions for namespacing (e.g., `module MyModule module NestedModule`). Do _not_ use `Module::Class` style for definitions. - -2. **Module Design:** - - - For utility modules containing stateless functions, use `module_function` at the start to export methods as module methods. - -3. **Naming:** - - - **Modules/Classes:** `PascalCase` - - **Methods/Variables:** `snake_case` - - **Predicate Methods:** End with `?` (e.g., `active?`) - - **Constants:** `SCREAMING_SNAKE_CASE` - - **Unused Parameters:** Prefix with `_` (e.g., `_unused_param`). - -4. **Methods:** - - - Use parentheses for definitions with parameters: `def my_method(param1, param2)`. - - Parentheses are optional for calls with no arguments or where unambiguous. - - Employ guard clauses (`return value if condition`) for early exits. - - Rely on implicit returns. - -5. **Documentation:** - - - Use **YARD** format for documenting methods (`@param`, `@return`). - -6. **Control Flow:** - - - Prefer `case/when` for multiple conditions on a single value, often using regex (`/pattern/`). - - Use `if/unless` for simpler conditions. - -7. **Data Structures:** - - - Use symbols (`:key`) for Hash keys. - - Use `Hash#dig` for safe access to nested hash values. - - Use `||` for default values. - - Use trailing commas in multi-line Hashes and Arrays. - -8. **Strings & Regex:** - - - Prefer single quotes (`'`) for strings without interpolation. - - Use literal regex syntax (`/.../`) and prefer `match?` for boolean checks. - -9. **Formatting:** - - 2-space indentation. - - Whitespace around operators and after commas. - - Break and indent chained method calls for readability. - -**Architecture:** - -- Organize code into clearly namespaced modules and classes. -- Create utility modules for shared, stateless logic (using `module_function`). -- Centralize configuration (e.g., in constants like `PRICES` in the example). From b064713c98aabd046e445e6ca62b686b263da171 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 17:54:26 -0500 Subject: [PATCH 24/30] Delete .cursor/rules/ruby_llm_philosophy.mdc --- .cursor/rules/ruby_llm_philosophy.mdc | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .cursor/rules/ruby_llm_philosophy.mdc diff --git a/.cursor/rules/ruby_llm_philosophy.mdc b/.cursor/rules/ruby_llm_philosophy.mdc deleted file mode 100644 index 87baf4d8..00000000 --- a/.cursor/rules/ruby_llm_philosophy.mdc +++ /dev/null @@ -1,15 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- - -# Ruby LLM Project Philosophy & Tone - -When working on the `ruby_llm` project (`crmne/ruby_llm`), embody the following principles: - -- **Developer-Centric & Joyful:** Prioritize developer experience (DX). Aim for code and APIs that are "delightful" and a "joy to use," removing unnecessary complexity ("No configuration madness, no complex callbacks, no handler hell"). -- **Expressive & Idiomatic Ruby:** Write "beautiful, expressive Ruby code." Follow idiomatic Ruby patterns and conventions. The goal is for developers to feel like they are writing natural Ruby. -- **Pragmatic & Unified:** Focus on solving real developer problems by providing a clean, unified interface ("One beautiful API for everything") that abstracts away inconsistencies between different AI providers. -- **Simple & Minimalist:** Favor simplicity and minimal dependencies. Keep the core lightweight. Code examples should be clear and concise. -- **Tone:** Enthusiastic, positive, problem/solution-oriented, concise, and slightly contrarian (positioning against unnecessary complexity). Align with Ruby community values of developer happiness and "taste." From 5a9b3b8a83e68b9f7808bdc0aa4a55f5787a64ca Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 15:57:28 -0700 Subject: [PATCH 25/30] fix: address PR comments - remove sleep calls and fix formatting --- docs/guides/rails.md | 1 - lib/generators/ruby_llm/install_generator.rb | 19 ++++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index 275380c0..bd7024fe 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -14,7 +14,6 @@ RubyLLM offers seamless integration with Ruby on Rails applications through help {: .fs-6 .fw-300 } ## Table of contents - {: .no_toc .text-delta } 1. TOC diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb index 9fe34d25..733abc80 100644 --- a/lib/generators/ruby_llm/install_generator.rb +++ b/lib/generators/ruby_llm/install_generator.rb @@ -71,22 +71,19 @@ def acts_as_tool_call_declaration def create_migration_files # Create migrations with timestamps to ensure proper order # First create chats table + timestamp = Time.now.utc migration_template 'create_chats_migration.rb.tt', - "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_#{options[:chat_model_name].tableize}.rb" + "db/migrate/#{timestamp.strftime('%Y%m%d%H%M%S')}_create_#{options[:chat_model_name].tableize}.rb" - # Wait 1 second to ensure different timestamp - sleep 1 - - # Then create tool_calls table + # Then create tool_calls table with timestamp + 1 second + timestamp += 1 migration_template 'create_tool_calls_migration.rb.tt', - "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_#{options[:tool_call_model_name].tableize}.rb" - - # Wait 1 second to ensure different timestamp - sleep 1 + "db/migrate/#{timestamp.strftime('%Y%m%d%H%M%S')}_create_#{options[:tool_call_model_name].tableize}.rb" - # Finally create messages table which references both previous tables + # Finally create messages table with timestamp + 2 seconds + timestamp += 1 migration_template 'create_messages_migration.rb.tt', - "db/migrate/#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_create_#{options[:message_model_name].tableize}.rb" + "db/migrate/#{timestamp.strftime('%Y%m%d%H%M%S')}_create_#{options[:message_model_name].tableize}.rb" end def create_model_files From 224b24e68001822354fa27bfe136131d4e277656 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 16:01:55 -0700 Subject: [PATCH 26/30] fix: revert rails.md to previous version as requested in PR comments --- docs/guides/rails.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index bd7024fe..c69fdf04 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -124,7 +124,7 @@ end Run the migrations: `rails db:migrate` -#### Set Up Models with `acts_as` Helpers +### Set Up Models with `acts_as` Helpers Include the RubyLLM helpers in your ActiveRecord models. @@ -157,9 +157,12 @@ class ToolCall < ApplicationRecord end ``` -{: .note } The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. +{: .note } +The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. +### Configure RubyLLM +Ensure your RubyLLM configuration (API keys, etc.) is set up, typically in `config/initializers/ruby_llm.rb`. See the [Installation Guide]({% link installation.md %}) for details. ## Basic Usage From 1fe4320c3fc038bc93cbc990b71cc0616c71b276 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 29 Apr 2025 16:02:37 -0700 Subject: [PATCH 27/30] fix: remove empty line in rails.md as requested in PR comments --- docs/guides/rails.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index c69fdf04..1c7ff1ec 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -7,7 +7,6 @@ permalink: /guides/rails --- # Rails Integration - {: .no_toc } RubyLLM offers seamless integration with Ruby on Rails applications through helpers for ActiveRecord models. This allows you to easily persist chat conversations, including messages and tool interactions, directly in your database. From 882f3fcdfdf247d5faaa28692caa3b5c4eff3970 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Jun 2025 10:55:51 -0700 Subject: [PATCH 28/30] fix: revert rails.md to original state as requested in PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review feedback from @crmne, reverting docs/guides/rails.md to its original state from the main branch. The Rails dependency remains correctly in the Gemfile (for development) but not in the gemspec (runtime dependencies). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/guides/rails.md | 382 +++++++++++++++++++++++++++++++++---------- 1 file changed, 299 insertions(+), 83 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index 1c7ff1ec..e03330d3 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -22,51 +22,45 @@ RubyLLM offers seamless integration with Ruby on Rails applications through help After reading this guide, you will know: -- How to set up ActiveRecord models for persisting chats and messages. -- How to use `acts_as_chat` and `acts_as_message`. -- How chat interactions automatically persist data. +* How to set up ActiveRecord models for persisting chats and messages. +* How the RubyLLM persistence flow works with Rails applications. +* How to use `acts_as_chat` and `acts_as_message` with your models. +* How to integrate streaming responses with Hotwire/Turbo Streams. +* How to customize the persistence behavior for validation-focused scenarios. -## Setup +## Understanding the Persistence Flow -### Using the Generator +Before diving into setup, it's important to understand how RubyLLM handles message persistence in Rails. This design influences model validations and real-time UI updates. -The simplest way to set up RubyLLM in your Rails application is to use the provided generator: +### How It Works -```bash -# Generate all necessary models, migrations, and configuration -rails generate ruby_llm:install -``` +When you call `chat_record.ask("What is the capital of France?")`, RubyLLM follows these steps: -This will create: +1. **Save the user message** with the question content. +2. **Call the `complete` method**, which: + - **Creates an empty assistant message** with blank content via the `on_new_message` callback + - **Makes the API call** to the AI provider using the conversation history + - **Process the response:** + - **On success**: Updates the assistant message with content, token counts, and tool call information via the `on_end_message` callback + - **On failure**: Cleans up by automatically destroying the empty assistant message -- A `Chat` model for storing chat sessions -- A `Message` model for storing individual messages -- A `ToolCall` model for storing tool calls -- Migrations for all these models -- A RubyLLM initializer +### Why This Design? -If you need to customize model names to avoid namespace collisions, you can provide options: - -```bash -rails generate ruby_llm:install \ - --chat-model-name=Conversation \ - --message-model-name=ChatMessage \ - --tool-call-model-name=FunctionCall -``` +This two-phase approach (create empty → update with content) is intentional and optimizes for real-time UI experiences: -After running the generator, simply run the migrations: +1. **Streaming-first design**: By creating the message record before the API call, your UI can immediately show a "thinking" state and have a DOM target ready for incoming chunks. +2. **Turbo Streams compatibility**: Works perfectly with `after_create_commit { broadcast_append_to... }` for real-time updates. +3. **Clean rollback on failure**: If the API call fails, the empty message is automatically removed. -```bash -rails db:migrate -``` +### Content Validation Implications -### Manual Setup (Alternative) +This approach has one important consequence: **you cannot use `validates :content, presence: true`** on your Message model because the initial creation step would fail validation. Later in the guide, we'll show an alternative approach if you need content validations. -If you prefer to set up the models manually, follow these steps: +## Setting Up Your Rails Application -#### Create Migrations +### Database Migrations -Generate migrations for your `Chat` and `Message` models. You'll also need a `ToolCall` model if you plan to use [Tools]({% link guides/tools.md %}). +First, generate migrations for your `Chat` and `Message` models. You'll also need a `ToolCall` model if you plan to use [Tools]({% link guides/tools.md %}). ```bash # Generate basic models and migrations @@ -123,9 +117,52 @@ end Run the migrations: `rails db:migrate` +### ActiveStorage Setup for Attachments (Optional) +{: .d-inline-block } + +Coming in v1.3.0 +{: .label .label-yellow } + +If you want to use attachments (images, audio, PDFs) with your AI chats, you need to set up ActiveStorage: + +```bash +# Only needed if you plan to use attachments +rails active_storage:install +rails db:migrate +``` + +Then add the attachments association to your Message model: + +```ruby +# app/models/message.rb +class Message < ApplicationRecord + acts_as_message # Basic RubyLLM integration + + # Optional: Add this line to enable attachment support + has_many_attached :attachments +end +``` + +This setup is completely optional - your RubyLLM Rails integration works fine without it if you don't need attachment support. + +### Configure RubyLLM + +Ensure your RubyLLM configuration (API keys, etc.) is set up, typically in `config/initializers/ruby_llm.rb`. See the [Installation Guide]({% link installation.md %}) for details. + +```ruby +# config/initializers/ruby_llm.rb +RubyLLM.configure do |config| + config.openai_api_key = ENV['OPENAI_API_KEY'] + # Add other provider configurations as needed + config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] + config.gemini_api_key = ENV['GEMINI_API_KEY'] + # ... +end +``` + ### Set Up Models with `acts_as` Helpers -Include the RubyLLM helpers in your ActiveRecord models. +Include the RubyLLM helpers in your ActiveRecord models: ```ruby # app/models/chat.rb @@ -145,6 +182,12 @@ class Message < ApplicationRecord acts_as_message # Assumes Chat and ToolCall model names # --- Add your standard Rails model logic below --- + # Note: Do NOT add "validates :content, presence: true" + # This would break the assistant message flow described above + + # These validations are fine: + validates :role, presence: true + validates :chat, presence: true end # app/models/tool_call.rb (Only if using tools) @@ -156,34 +199,29 @@ class ToolCall < ApplicationRecord end ``` -{: .note } -The `acts_as` helpers primarily handle loading history and saving messages/tool calls related to the chat interaction. Add your application-specific logic (associations, validations, scopes, callbacks) as usual. - -### Configure RubyLLM - -Ensure your RubyLLM configuration (API keys, etc.) is set up, typically in `config/initializers/ruby_llm.rb`. See the [Installation Guide]({% link installation.md %}) for details. - ## Basic Usage -The `acts_as_chat` helper delegates common `RubyLLM::Chat` methods to your `Chat` model. When you call these methods on an ActiveRecord `Chat` instance, RubyLLM automatically handles persistence. +Once your models are set up, the `acts_as_chat` helper delegates common `RubyLLM::Chat` methods to your `Chat` model: ```ruby # Create a new chat record chat_record = Chat.create!(model_id: 'gpt-4.1-nano', user: current_user) -# The `model_id` should typically be a valid identifier known to RubyLLM. -# See the [Working with Models Guide]({% link guides/models.md %}) for details. - -# Ask a question. This automatically: -# 1. Saves the user message ("What is the capital...") -# 2. Makes the API call with history -# 3. Saves the assistant message (the response) -response = chat_record.ask "What is the capital of France?" - -# `response` is the RubyLLM::Message object from the API call. -# The persisted record is associated with `chat_record`. -assistant_message_record = chat_record.messages.last -puts assistant_message_record.content # => "The capital of France is Paris." +# Ask a question - the persistence flow runs automatically +begin + # This saves the user message, then calls complete() which: + # 1. Creates an empty assistant message + # 2. Makes the API call + # 3. Updates the message on success, or destroys it on failure + response = chat_record.ask "What is the capital of France?" + + # Get the persisted message record from the database + assistant_message_record = chat_record.messages.last + puts assistant_message_record.content # => "The capital of France is Paris." +rescue RubyLLM::Error => e + puts "API Call Failed: #{e.message}" + # The empty assistant message is automatically cleaned up on failure +end # Continue the conversation chat_record.ask "Tell me more about that city" @@ -192,9 +230,9 @@ chat_record.ask "Tell me more about that city" puts "Conversation length: #{chat_record.messages.count}" # => 4 ``` -## Persisting Instructions +### System Instructions -Instructions (system prompts) set via `with_instructions` are also automatically persisted as `Message` records with the `system` role. +Instructions (system prompts) set via `with_instructions` are also automatically persisted as `Message` records with the `system` role: ```ruby chat_record = Chat.create!(model_id: 'gpt-4.1-nano') @@ -202,44 +240,159 @@ chat_record = Chat.create!(model_id: 'gpt-4.1-nano') # This creates and saves a Message record with role: :system chat_record.with_instructions("You are a Ruby expert.") -# This replaces the previous system message in the database +# Replace all system messages with a new one chat_record.with_instructions("You are a concise Ruby expert.", replace: true) system_message = chat_record.messages.find_by(role: :system) puts system_message.content # => "You are a concise Ruby expert." ``` -## Streaming Responses with Hotwire/Turbo +### Tools Integration + +If you're using [Tools]({% link guides/tools.md %}), they're automatically persisted too: + +```ruby +# Define a tool +class Weather < RubyLLM::Tool + description "Gets current weather for a location" + param :city, desc: "City name" + + def execute(city:) + "The weather in #{city} is sunny and 22°C." + end +end -You can combine `acts_as_chat` with streaming and Turbo Streams for real-time UI updates. The persistence logic works seamlessly alongside the streaming block. +# Use tools with your persisted chat +chat_record = Chat.create!(model_id: 'gpt-4.1-nano') +chat_record.with_tool(Weather) +response = chat_record.ask("What's the weather in Paris?") + +# The tool call and its result are persisted +puts chat_record.messages.count # => 3 (user, assistant's tool call, tool result) +``` + +### Working with Attachments +{: .d-inline-block } -Here's a simplified approach using a background job: +Coming in v1.3.0 +{: .label .label-yellow } + +If you've set up ActiveStorage as described above, you can easily send attachments to AI models with automatic type detection: ```ruby -# app/jobs/chat_job.rb -class ChatJob < ApplicationJob - def perform(chat_id, question) - chat = Chat.find(chat_id) - chat.ask(question) do |chunk| - Turbo::StreamsChannel.broadcast_append_to( - chat, target: "response", partial: "messages/chunk", locals: { chunk: chunk } - ) +# Create a chat +chat_record = Chat.create!(model_id: 'claude-3-5-sonnet') + +# Send a single file - type automatically detected +chat_record.ask("What's in this file?", with: "app/assets/images/diagram.png") + +# Send multiple files of different types - all automatically detected +chat_record.ask("What are in these files?", with: [ + "app/assets/documents/report.pdf", + "app/assets/images/chart.jpg", + "app/assets/audio/recording.mp3" +]) + +# Still works with manually categorized hash (backward compatible) +chat_record.ask("What's in this image?", with: { + image: "app/assets/images/diagram.png" +}) + +# Works with file uploads from forms +chat_record.ask("Analyze this file", with: params[:uploaded_file]) + +# Works with existing ActiveStorage attachments +chat_record.ask("What's in this document?", with: user.profile_document) +``` + +The attachment API automatically detects file types based on file extension or content type, so you don't need to specify whether something is an image, audio file, or PDF - RubyLLM figures it out for you! + +## Handling Persistence Edge Cases + +### Orphaned Empty Messages + +While the error-handling logic destroys empty assistant messages when API calls fail, there might be situations where empty messages remain (e.g., server crashes, connection drops). You can clean these up with: + +```ruby +# Delete any empty assistant messages +Message.where(role: "assistant", content: "").destroy_all +``` + +### Providers with Empty Content Restrictions + +Some providers (like Gemini) reject conversations with empty message content. If you're using these providers, ensure you've cleaned up any empty messages in your database before making API calls. + +## Alternative: Validation-First Approach + +If your application requires content validations or you prefer a different persistence flow, you can override the default methods to use a "validate-first" approach: + +```ruby +# app/models/chat.rb +class Chat < ApplicationRecord + acts_as_chat + + # Override the default persistence methods + private + + def persist_new_message + # Create a new message object but don't save it yet + @message = messages.new(role: :assistant) + end + + def persist_message_completion(message) + return unless message + + # Fill in attributes and save once we have content + @message.assign_attributes( + content: message.content, + model_id: message.model_id, + input_tokens: message.input_tokens, + output_tokens: message.output_tokens + ) + + @message.save! + + # Handle tool calls if present + persist_tool_calls(message.tool_calls) if message.tool_calls.present? + end + + def persist_tool_calls(tool_calls) + tool_calls.each_value do |tool_call| + attributes = tool_call.to_h + attributes[:tool_call_id] = attributes.delete(:id) + @message.tool_calls.create!(**attributes) end end end +# app/models/message.rb +class Message < ApplicationRecord + acts_as_message + + # Now you can safely add this validation + validates :content, presence: true +end +``` + +With this approach: +1. The assistant message is only created and saved after receiving a valid API response +2. Content validations work as expected +3. The trade-off is that you lose the ability to target the assistant message DOM element for streaming updates before the API call completes + +## Streaming Responses with Hotwire/Turbo + +The default persistence flow is designed to work seamlessly with streaming and Turbo Streams for real-time UI updates. Here's a simplified approach using a background job: + +```ruby # app/models/chat.rb class Chat < ApplicationRecord acts_as_chat - belongs_to :user, optional: true - # Broadcast message creations to the chat channel broadcasts_to ->(chat) { [chat, "messages"] } end # app/models/message.rb class Message < ApplicationRecord acts_as_message - # Broadcast updates to self (for streaming into the message frame) broadcasts_to ->(message) { [message.chat, "messages"] } # Helper to broadcast chunks during streaming @@ -256,18 +409,15 @@ class ChatStreamJob < ApplicationJob def perform(chat_id, user_content) chat = Chat.find(chat_id) - # The `ask` method automatically saves the user message first. - # It then creates the assistant message record *before* streaming starts, - # and updates it with the final content/tokens upon completion. chat.ask(user_content) do |chunk| - # Get the latest (assistant) message record, which was created by `ask` + # Get the assistant message record (created before streaming starts) assistant_message = chat.messages.last if chunk.content && assistant_message # Append the chunk content to the message's target div assistant_message.broadcast_append_chunk(chunk.content) end end - # Final assistant message is now fully persisted by acts_as_chat + # Final assistant message is now fully persisted end end ``` @@ -282,7 +432,7 @@ end <%= form_with(url: chat_messages_path(@chat), method: :post) do |f| %> <%= f.text_area :content %> - <%= f.button "Send", disable_with: "Sending..." %> + <%= f.submit "Send" %> <% end %> <%# app/views/messages/_message.html.erb %> @@ -290,22 +440,88 @@ end
<%= message.role.capitalize %>: <%# Target div for streaming content %> -
" class="message-content"> +
" style="display: inline;"> <%# Render initial content if not streaming, otherwise job appends here %> - <%= simple_format(message.content) %> + <%= message.content.present? ? simple_format(message.content) : '...'.html_safe %>
<% end %> ``` -{: .note } This example shows the core idea. You'll need to adapt the broadcasting, targets, and partials for your specific UI needs (e.g., handling Markdown rendering, adding styling, showing typing indicators). +### Controller Integration + +Putting it all together in a controller: + +```ruby +# app/controllers/messages_controller.rb +class MessagesController < ApplicationController + before_action :set_chat + + def create + message_content = params[:content] + + # Queue the background job to handle the streaming response + ChatStreamJob.perform_later(@chat.id, message_content) + + # Immediately return success to the user + respond_to do |format| + format.turbo_stream { head :ok } + format.html { redirect_to @chat } + end + end + + private + + def set_chat + @chat = Chat.find(params[:chat_id]) + end +end +``` + +This setup allows for: +1. Real-time UI updates as the AI generates its response +2. Background processing to prevent request timeouts +3. Automatic persistence of all messages and tool calls ## Customizing Models Your `Chat`, `Message`, and `ToolCall` models are standard ActiveRecord models. You can add any other associations, validations, scopes, callbacks, or methods as needed for your application logic. The `acts_as` helpers provide the core persistence bridge to RubyLLM without interfering with other model behavior. +Some common customizations include: + +```ruby +# app/models/chat.rb +class Chat < ApplicationRecord + acts_as_chat + + # Add typical Rails associations + belongs_to :user + has_many :favorites, dependent: :destroy + + # Add scopes + scope :recent, -> { order(updated_at: :desc) } + scope :with_responses, -> { joins(:messages).where(messages: { role: 'assistant' }).distinct } + + # Add custom methods + def summary + messages.last(2).map(&:content).join(' ... ') + end + + # Add callbacks + after_create :notify_administrators + + private + + def notify_administrators + # Custom logic + end +end +``` + ## Next Steps -- [Chatting with AI Models]({% link guides/chat.md %}) -- [Using Tools]({% link guides/tools.md %}) -- [Working with Models]({% link guides/models.md %}) +* [Chatting with AI Models]({% link guides/chat.md %}) +* [Using Tools]({% link guides/tools.md %}) +* [Streaming Responses]({% link guides/streaming.md %}) +* [Working with Models]({% link guides/models.md %}) +* [Error Handling]({% link guides/error-handling.md %}) \ No newline at end of file From 4789f807ef3b8da4c8081731c086b293ee241092 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Jun 2025 10:58:00 -0700 Subject: [PATCH 29/30] test: update generator specs to match INSTALL_INFO rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated specs to reflect the renaming of README.md.tt to INSTALL_INFO.md.tt and the method name change from show_readme to show_install_info. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../ruby_llm/install_generator_spec.rb | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/spec/lib/generators/ruby_llm/install_generator_spec.rb b/spec/lib/generators/ruby_llm/install_generator_spec.rb index f094feb8..589252f8 100644 --- a/spec/lib/generators/ruby_llm/install_generator_spec.rb +++ b/spec/lib/generators/ruby_llm/install_generator_spec.rb @@ -134,35 +134,35 @@ end end - describe 'README template' do - let(:readme_content) { File.read(File.join(template_dir, 'README.md.tt')) } + describe 'INSTALL_INFO template' do + let(:install_info_content) { File.read(File.join(template_dir, 'INSTALL_INFO.md.tt')) } - it 'has README template file' do - expect(File.exist?(File.join(template_dir, 'README.md.tt'))).to be(true) + it 'has INSTALL_INFO template file' do + expect(File.exist?(File.join(template_dir, 'INSTALL_INFO.md.tt'))).to be(true) end it 'includes welcome message' do - expect(readme_content).to include('RubyLLM Rails Setup Complete') + expect(install_info_content).to include('RubyLLM Rails Setup Complete') end it 'includes setup information' do - expect(readme_content).to include('Run migrations') + expect(install_info_content).to include('Run migrations') end it 'includes migration instructions' do - expect(readme_content).to include('rails db:migrate') + expect(install_info_content).to include('rails db:migrate') end it 'includes API configuration instructions' do - expect(readme_content).to include('Set your API keys') + expect(install_info_content).to include('Set your API keys') end it 'includes usage examples' do - expect(readme_content).to include('Start using RubyLLM in your code') + expect(install_info_content).to include('Start using RubyLLM in your code') end it 'includes streaming response information' do - expect(readme_content).to include('For streaming responses') + expect(install_info_content).to include('For streaming responses') end end @@ -197,8 +197,8 @@ expect(generator_content).to include('def create_initializer') end - it 'defines show_readme method' do - expect(generator_content).to include('def show_readme') + it 'defines show_install_info method' do + expect(generator_content).to include('def show_install_info') end end From bf2244af7b7b84244bdd57ddba04d4f5df5dc23a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Jun 2025 11:12:27 -0700 Subject: [PATCH 30/30] docs: update Rails guide to include quick setup instructions with generator --- docs/guides/rails.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/guides/rails.md b/docs/guides/rails.md index 0c7d0965..18123383 100644 --- a/docs/guides/rails.md +++ b/docs/guides/rails.md @@ -58,9 +58,41 @@ This approach has one important consequence: **you cannot use `validates :conten ## Setting Up Your Rails Application -### Database Migrations +### Quick Setup with Generator -First, generate migrations for your `Chat`, `Message`, and `ToolCall` models. +The easiest way to get started is using the provided Rails generator: + +```bash +rails generate ruby_llm:install +``` + +This generator automatically creates: +- All required migrations (Chat, Message, ToolCall tables) +- Model files with `acts_as_chat`, `acts_as_message`, and `acts_as_tool_call` configured +- A RubyLLM initializer in `config/initializers/ruby_llm.rb` + +After running the generator: + +```bash +rails db:migrate +``` + +You're ready to go! The generator handles all the setup complexity for you. + +#### Generator Options + +The generator supports custom model names if needed: + +```bash +# Use custom model names +rails generate ruby_llm:install --chat-model-name=Conversation --message-model-name=ChatMessage --tool-call-model-name=FunctionCall +``` + +This is useful if you already have models with these names or prefer different naming conventions. + +### Manual Setup + +If you prefer to set up manually or need custom table/model names, you can create the migrations yourself: ```bash # Generate basic models and migrations @@ -69,7 +101,7 @@ rails g model Message chat:references role:string content:text model_id:string i rails g model ToolCall message:references tool_call_id:string:index name:string arguments:jsonb ``` -Adjust the migrations as needed (e.g., `null: false` constraints, `jsonb` type for PostgreSQL). +Then adjust the migrations as needed (e.g., `null: false` constraints, `jsonb` type for PostgreSQL). ```ruby # db/migrate/YYYYMMDDHHMMSS_create_chats.rb