diff --git a/README.md b/README.md index 42c263e6..7153919a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,32 @@ See the [Installation Guide](https://rubyllm.com/installation) for full details. Add persistence to your chat models effortlessly: +### Option 1: Rails Generate + +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: Manually + +Or, add RubyLLM to your existing ActiveRecord models: + ```ruby # app/models/chat.rb class Chat < ApplicationRecord 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 diff --git a/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt b/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt new file mode 100644 index 00000000..6b529abb --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt @@ -0,0 +1,75 @@ +# RubyLLM Rails Setup Complete! + +Thanks for installing RubyLLM in your Rails application. Here's what was created: + +## 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 + +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 + # 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") + ChatJob.perform_later(chat.id, "What's your favorite Ruby gem?") + ``` + +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/lib/generators/ruby_llm/install/templates/chat_model.rb.tt b/lib/generators/ruby_llm/install/templates/chat_model.rb.tt new file mode 100644 index 00000000..c8030bd0 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/chat_model.rb.tt @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..a5f6329e --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt @@ -0,0 +1,8 @@ +class Create<%= options[:chat_model_name].pluralize %> < ActiveRecord::Migration<%= migration_version %> + def change + create_table :<%= options[:chat_model_name].tableize %> 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.tt b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt new file mode 100644 index 00000000..b036cac0 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt @@ -0,0 +1,15 @@ +# 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| + 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 :<%= options[:tool_call_model_name].tableize.singularize %> + t.timestamps + end + 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 new file mode 100644 index 00000000..f3a3d200 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt @@ -0,0 +1,14 @@ +<%#- # 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 :<%= 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 + t.<%= postgresql? ? 'jsonb' : 'json' %> :arguments, default: {} + t.timestamps + end + + 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 new file mode 100644 index 00000000..3fd92ff7 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/initializer.rb.tt @@ -0,0 +1,14 @@ +# 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.tt b/lib/generators/ruby_llm/install/templates/message_model.rb.tt new file mode 100644 index 00000000..2875c2be --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/message_model.rb.tt @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..11df7429 --- /dev/null +++ b/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..733abc80 --- /dev/null +++ b/lib/generators/ruby_llm/install_generator.rb @@ -0,0 +1,104 @@ +# 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 + 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) + end + + def migration_version + "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" + end + + def postgresql? + ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') + rescue StandardError + false + end + + def acts_as_chat_declaration + acts_as_chat_params = [] + 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' + end + end + + def acts_as_message_declaration + acts_as_message_params = [] + 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' + end + end + + def acts_as_tool_call_declaration + acts_as_tool_call_params = [] + 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' + end + end + + 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/#{timestamp.strftime('%Y%m%d%H%M%S')}_create_#{options[:chat_model_name].tableize}.rb" + + # Then create tool_calls table with timestamp + 1 second + timestamp += 1 + migration_template 'create_tool_calls_migration.rb.tt', + "db/migrate/#{timestamp.strftime('%Y%m%d%H%M%S')}_create_#{options[:tool_call_model_name].tableize}.rb" + + # Finally create messages table with timestamp + 2 seconds + timestamp += 1 + migration_template 'create_messages_migration.rb.tt', + "db/migrate/#{timestamp.strftime('%Y%m%d%H%M%S')}_create_#{options[:message_model_name].tableize}.rb" + end + + def create_model_files + 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 + template 'initializer.rb.tt', 'config/initializers/ruby_llm.rb' + end + + def show_install_info + content = ERB.new(File.read(source_paths.first + '/INSTALL_INFO.md.tt')).result(binding) + say content + end + end +end diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index 6c2e284e..bc19b931 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -23,6 +23,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/lib/ruby_llm/railtie.rb b/lib/ruby_llm/railtie.rb index e1b8610c..ba7323b7 100644 --- a/lib/ruby_llm/railtie.rb +++ b/lib/ruby_llm/railtie.rb @@ -8,5 +8,16 @@ class Railtie < Rails::Railtie include RubyLLM::ActiveRecord::ActsAs end end + + # Include rake tasks + rake_tasks do + path = File.expand_path(__dir__) + Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f } + end + + # Register generators + generators do + require 'generators/ruby_llm/install_generator' + end end end 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..589252f8 --- /dev/null +++ b/spec/lib/generators/ruby_llm/install_generator_spec.rb @@ -0,0 +1,224 @@ +# 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) { 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 + [ + '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 :<%= options[:chat_model_name].tableize %>') + 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 :<%= options[:message_model_name].tableize %>') + end + + it 'includes chat reference' do + expect(message_migration).to include('t.references :<%= options[:chat_model_name].tableize.singularize %>, null: false, foreign_key: true') + 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 :<%= options[:tool_call_model_name].tableize %>') + 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("t.<%= postgresql? ? 'jsonb' : 'json' %> :arguments, default: {}") + 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 'INSTALL_INFO template' do + let(:install_info_content) { File.read(File.join(template_dir, 'INSTALL_INFO.md.tt')) } + + 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(install_info_content).to include('RubyLLM Rails Setup Complete') + end + + it 'includes setup information' do + expect(install_info_content).to include('Run migrations') + end + + it 'includes migration instructions' do + expect(install_info_content).to include('rails db:migrate') + end + + it 'includes API configuration instructions' do + expect(install_info_content).to include('Set your API keys') + end + + it 'includes usage examples' do + expect(install_info_content).to include('Start using RubyLLM in your code') + end + + it 'includes streaming response information' do + expect(install_info_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_install_info method' do + expect(generator_content).to include('def show_install_info') + 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