diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 157ba7b42..61fabcd65 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -49,8 +49,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - lfs: true - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -99,8 +97,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - lfs: true - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 556e5f0d1..864829804 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,8 +24,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - lfs: true - name: Setup Ruby for models guide generation (root Gemfile) uses: ruby/setup-ruby@v1 diff --git a/README.md b/README.md index 87387c4d2..d9a0f303a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Battle tested at [Chat with Work](https://chatwithwork.com) — *Claude Code for your documents* -[![Gem Version](https://badge.fury.io/rb/ruby_llm.svg?a=7)](https://badge.fury.io/rb/ruby_llm) +[![Gem Version](https://badge.fury.io/rb/ruby_llm.svg?a=8)](https://badge.fury.io/rb/ruby_llm) [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard) [![Gem Downloads](https://img.shields.io/gem/dt/ruby_llm)](https://rubygems.org/gems/ruby_llm) [![codecov](https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg?a=2)](https://codecov.io/gh/crmne/ruby_llm) diff --git a/docs/_advanced/upgrading-to-1.7.md b/docs/_advanced/upgrading-to-1.7.md index af3d0cd9b..c1af4d4ad 100644 --- a/docs/_advanced/upgrading-to-1.7.md +++ b/docs/_advanced/upgrading-to-1.7.md @@ -34,7 +34,9 @@ rails db:migrate That's it! The generator: - Creates the models table if needed - Automatically adds `config.use_new_acts_as = true` to your initializer +- Automatically updates your existing models' `acts_as` declarations to the new version - Migrates your existing data to use foreign keys +- Loads the models in the db - Preserves all your data (old string columns renamed to `model_id_string`) ### Custom Model Names @@ -187,6 +189,31 @@ The chat UI works with your existing Chat and Message models and includes: - Code syntax highlighting - Responsive design +## Troubleshooting + +### "undefined local variable or method 'acts_as_model'" error during migration + +If you get this error when running `rails db:migrate`, add the configuration to `config/application.rb` **before** your Application class: + +```ruby +# config/application.rb +require_relative "boot" +require "rails/all" + +# Configure RubyLLM before Rails::Application is inherited +RubyLLM.configure do |config| + config.use_new_acts_as = true +end + +module YourApp + class Application < Rails::Application + # ... + end +end +``` + +This ensures RubyLLM is configured before ActiveRecord loads your models. + ## New Applications Fresh installs get the model registry automatically: diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index 1b031bd85..388229638 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - ruby_llm (1.7.0) + ruby_llm (1.7.1) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -98,7 +98,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.31.0) + async (2.32.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock index 3a533a585..be7564e12 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - ruby_llm (1.7.0) + ruby_llm (1.7.1) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -92,7 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.31.0) + async (2.32.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index 3a02ad918..794a571e6 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - ruby_llm (1.7.0) + ruby_llm (1.7.1) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -92,7 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.31.0) + async (2.32.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) diff --git a/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb b/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb index 93f9a7992..d551fb4d1 100644 --- a/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +++ b/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb @@ -38,35 +38,39 @@ def parse_model_mappings @model_names ||= parse_model_mappings @model_names[type] end + + define_method("#{type}_table_name") do + table_name_for(send("#{type}_model_name")) + end end def create_views # Chat views - template 'views/chats/index.html.erb', "app/views/#{chat_model_name.tableize}/index.html.erb" - template 'views/chats/new.html.erb', "app/views/#{chat_model_name.tableize}/new.html.erb" - template 'views/chats/show.html.erb', "app/views/#{chat_model_name.tableize}/show.html.erb" + template 'views/chats/index.html.erb', "app/views/#{chat_table_name}/index.html.erb" + template 'views/chats/new.html.erb', "app/views/#{chat_table_name}/new.html.erb" + template 'views/chats/show.html.erb', "app/views/#{chat_table_name}/show.html.erb" template 'views/chats/_chat.html.erb', - "app/views/#{chat_model_name.tableize}/_#{chat_model_name.underscore}.html.erb" - template 'views/chats/_form.html.erb', "app/views/#{chat_model_name.tableize}/_form.html.erb" + "app/views/#{chat_table_name}/_#{chat_model_name.underscore}.html.erb" + template 'views/chats/_form.html.erb', "app/views/#{chat_table_name}/_form.html.erb" # Message views template 'views/messages/_message.html.erb', - "app/views/#{message_model_name.tableize}/_#{message_model_name.underscore}.html.erb" - template 'views/messages/_form.html.erb', "app/views/#{message_model_name.tableize}/_form.html.erb" + "app/views/#{message_table_name}/_#{message_model_name.underscore}.html.erb" + template 'views/messages/_form.html.erb', "app/views/#{message_table_name}/_form.html.erb" template 'views/messages/create.turbo_stream.erb', - "app/views/#{message_model_name.tableize}/create.turbo_stream.erb" + "app/views/#{message_table_name}/create.turbo_stream.erb" # Model views - template 'views/models/index.html.erb', "app/views/#{model_model_name.tableize}/index.html.erb" - template 'views/models/show.html.erb', "app/views/#{model_model_name.tableize}/show.html.erb" + template 'views/models/index.html.erb', "app/views/#{model_table_name}/index.html.erb" + template 'views/models/show.html.erb', "app/views/#{model_table_name}/show.html.erb" template 'views/models/_model.html.erb', - "app/views/#{model_model_name.tableize}/_#{model_model_name.underscore}.html.erb" + "app/views/#{model_table_name}/_#{model_model_name.underscore}.html.erb" end def create_controllers - template 'controllers/chats_controller.rb', "app/controllers/#{chat_model_name.tableize}_controller.rb" - template 'controllers/messages_controller.rb', "app/controllers/#{message_model_name.tableize}_controller.rb" - template 'controllers/models_controller.rb', "app/controllers/#{model_model_name.tableize}_controller.rb" + template 'controllers/chats_controller.rb', "app/controllers/#{chat_table_name}_controller.rb" + template 'controllers/messages_controller.rb', "app/controllers/#{message_table_name}_controller.rb" + template 'controllers/models_controller.rb', "app/controllers/#{model_table_name}_controller.rb" end def create_jobs @@ -75,7 +79,7 @@ def create_jobs def add_routes model_routes = <<~ROUTES.strip - resources :#{model_model_name.tableize}, only: [:index, :show] do + resources :#{model_table_name}, only: [:index, :show] do collection do post :refresh end @@ -83,8 +87,8 @@ def add_routes ROUTES route model_routes chat_routes = <<~ROUTES.strip - resources :#{chat_model_name.tableize} do - resources :#{message_model_name.tableize}, only: [:create] + resources :#{chat_table_name} do + resources :#{message_table_name}, only: [:create] end ROUTES route chat_routes @@ -107,9 +111,17 @@ def display_post_install_message return unless behavior == :invoke say "\n ✅ Chat UI installed!", :green - say "\n Start your server and visit http://localhost:3000/#{chat_model_name.tableize}", :cyan + say "\n Start your server and visit http://localhost:3000/#{chat_table_name}", :cyan say "\n" end + + private + + def table_name_for(model_name) + # Convert namespaced model names to proper table names + # e.g., "Assistant::Chat" -> "assistant_chats" (not "assistant/chats") + model_name.underscore.pluralize.tr('/', '_') + end end end end diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb new file mode 100644 index 000000000..abc313ac4 --- /dev/null +++ b/lib/generators/ruby_llm/generator_helpers.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module RubyLLM + # Shared helpers for RubyLLM generators + module GeneratorHelpers + def parse_model_mappings + @model_names = { + chat: 'Chat', + message: 'Message', + tool_call: 'ToolCall', + model: 'Model' + } + + model_mappings.each do |mapping| + if mapping.include?(':') + key, value = mapping.split(':', 2) + @model_names[key.to_sym] = value.classify + end + end + + @model_names + end + + %i[chat message tool_call model].each do |type| + define_method("#{type}_model_name") do + @model_names ||= parse_model_mappings + @model_names[type] + end + + define_method("#{type}_table_name") do + table_name_for(send("#{type}_model_name")) + end + end + + def acts_as_chat_declaration + params = [] + + add_association_params(params, :messages, message_table_name, message_model_name, plural: true) + add_association_params(params, :model, model_table_name, model_model_name) + + "acts_as_chat#{" #{params.join(', ')}" if params.any?}" + end + + def acts_as_message_declaration + params = [] + + add_association_params(params, :chat, chat_table_name, chat_model_name) + add_association_params(params, :tool_calls, tool_call_table_name, tool_call_model_name, plural: true) + add_association_params(params, :model, model_table_name, model_model_name) + + "acts_as_message#{" #{params.join(', ')}" if params.any?}" + end + + def acts_as_model_declaration + params = [] + + add_association_params(params, :chats, chat_table_name, chat_model_name, plural: true) + + "acts_as_model#{" #{params.join(', ')}" if params.any?}" + end + + def acts_as_tool_call_declaration + params = [] + + add_association_params(params, :message, message_table_name, message_model_name) + + "acts_as_tool_call#{" #{params.join(', ')}" if params.any?}" + end + + def create_namespace_modules + namespaces = [] + + [chat_model_name, message_model_name, tool_call_model_name, model_model_name].each do |model_name| + if model_name.include?('::') + namespace = model_name.split('::').first + namespaces << namespace unless namespaces.include?(namespace) + end + end + + namespaces.each do |namespace| + module_path = "app/models/#{namespace.underscore}.rb" + next if File.exist?(Rails.root.join(module_path)) + + create_file module_path do + <<~RUBY + module #{namespace} + def self.table_name_prefix + "#{namespace.underscore}_" + end + end + RUBY + end + end + 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 table_exists?(table_name) + ::ActiveRecord::Base.connection.table_exists?(table_name) + rescue StandardError + false + end + + private + + def add_association_params(params, default_assoc, table_name, model_name, plural: false) + assoc = plural ? table_name.to_sym : table_name.singularize.to_sym + + return if assoc == default_assoc + + params << "#{default_assoc}: :#{assoc}" + params << "#{default_assoc.to_s.singularize}_class: '#{model_name}'" if model_name != assoc.to_s.classify + end + + def table_name_for(model_name) + # Convert namespaced model names to proper table names + # e.g., "Assistant::Chat" -> "assistant_chats" (not "assistant/chats") + model_name.underscore.pluralize.tr('/', '_') + end + end +end diff --git a/lib/generators/ruby_llm/install/install_generator.rb b/lib/generators/ruby_llm/install/install_generator.rb new file mode 100644 index 000000000..4b48b744e --- /dev/null +++ b/lib/generators/ruby_llm/install/install_generator.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails/generators' +require 'rails/generators/active_record' +require_relative '../generator_helpers' + +module RubyLLM + # Generator for RubyLLM Rails models and migrations + class InstallGenerator < Rails::Generators::Base + include Rails::Generators::Migration + include RubyLLM::GeneratorHelpers + + namespace 'ruby_llm:install' + + source_root File.expand_path('templates', __dir__) + + argument :model_mappings, type: :array, default: [], banner: 'chat:ChatName message:MessageName ...' + + class_option :skip_active_storage, type: :boolean, default: false, + desc: 'Skip ActiveStorage installation and attachment setup' + + desc 'Creates models and migrations for RubyLLM Rails integration\n' \ + 'Usage: rails g ruby_llm:install [chat:ChatName] [message:MessageName] ...' + + def self.next_migration_number(dirname) + ::ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + def create_migration_files + # Create migrations with timestamps to ensure proper order + # First create chats table + migration_template 'create_chats_migration.rb.tt', + "db/migrate/create_#{chat_table_name}.rb" + + # Then create messages table (must come before tool_calls due to foreign key) + sleep 1 # Ensure different timestamp + migration_template 'create_messages_migration.rb.tt', + "db/migrate/create_#{message_table_name}.rb" + + # Then create tool_calls table (references messages) + sleep 1 # Ensure different timestamp + migration_template 'create_tool_calls_migration.rb.tt', + "db/migrate/create_#{tool_call_table_name}.rb" + + # Create models table + sleep 1 # Ensure different timestamp + migration_template 'create_models_migration.rb.tt', + "db/migrate/create_#{model_table_name}.rb" + end + + def create_model_files + create_namespace_modules + + template 'chat_model.rb.tt', "app/models/#{chat_model_name.underscore}.rb" + template 'message_model.rb.tt', "app/models/#{message_model_name.underscore}.rb" + template 'tool_call_model.rb.tt', "app/models/#{tool_call_model_name.underscore}.rb" + + template 'model_model.rb.tt', "app/models/#{model_model_name.underscore}.rb" + end + + def create_initializer + template 'initializer.rb.tt', 'config/initializers/ruby_llm.rb' + end + + def install_active_storage + return if options[:skip_active_storage] + + say ' Installing ActiveStorage for file attachments...', :cyan + rails_command 'active_storage:install' + end + + def show_install_info + say "\n ✅ RubyLLM installed!", :green + + say ' ✅ ActiveStorage configured for file attachments support', :green unless options[:skip_active_storage] + + say "\n Next steps:", :yellow + say ' 1. Run: rails db:migrate' + say ' 2. Set your API keys in config/initializers/ruby_llm.rb' + + say " 3. Start chatting: #{chat_model_name}.create!(model: 'gpt-4.1-nano').ask('Hello!')" + + say "\n 🚀 Model registry is database-backed!", :cyan + say ' Models automatically load from the database' + say ' Pass model names as strings - RubyLLM handles the rest!' + say " Specify provider when needed: Chat.create!(model: 'gemini-2.5-flash', provider: 'vertexai')" + + if options[:skip_active_storage] + say "\n 📎 Note: ActiveStorage was skipped", :yellow + say ' File attachments won\'t work without ActiveStorage.' + say ' To enable later:' + say ' 1. Run: rails active_storage:install && rails db:migrate' + say " 2. Add to your #{message_model_name} model: has_many_attached :attachments" + end + + say "\n 📚 Documentation: https://rubyllm.com", :cyan + + say "\n ❤️ Love RubyLLM?", :magenta + say ' • ⭐ Star on GitHub: https://github.com/crmne/ruby_llm' + say ' • 🐦 Follow for updates: https://x.com/paolino' + say "\n" + end + end +end 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 0c10a2a9d..f76237158 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,7 +1,7 @@ -class Create<%= chat_model_name.pluralize %> < ActiveRecord::Migration<%= migration_version %> +class Create<%= chat_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :<%= chat_model_name.tableize %> do |t| - t.references :<%= model_model_name.tableize.singularize %>, foreign_key: true + create_table :<%= chat_table_name %> do |t| + t.references :<%= model_table_name.singularize %>, foreign_key: true t.timestamps end 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 fca241431..3b1fb3dfc 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,16 +1,16 @@ -class Create<%= message_model_name.pluralize %> < ActiveRecord::Migration<%= migration_version %> +class Create<%= message_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :<%= message_model_name.tableize %> do |t| - t.references :<%= chat_model_name.tableize.singularize %>, null: false, foreign_key: true + create_table :<%= message_table_name %> do |t| + t.references :<%= chat_table_name.singularize %>, null: false, foreign_key: true t.string :role, null: false t.text :content - t.references :<%= model_model_name.tableize.singularize %>, foreign_key: true + t.references :<%= model_table_name.singularize %>, foreign_key: true t.integer :input_tokens t.integer :output_tokens - t.references :<%= tool_call_model_name.tableize.singularize %>, foreign_key: true + t.references :<%= tool_call_table_name.singularize %>, foreign_key: true t.timestamps end - add_index :<%= message_model_name.tableize %>, :role + add_index :<%= message_table_name %>, :role end end diff --git a/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt b/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt index 14b70ef19..efdcd9546 100644 --- a/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +++ b/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt @@ -1,6 +1,6 @@ -class Create<%= model_model_name.pluralize %> < ActiveRecord::Migration<%= migration_version %> +class Create<%= model_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :<%= model_model_name.tableize %> do |t| + create_table :<%= model_table_name %> do |t| t.string :model_id, null: false t.string :name, null: false t.string :provider, null: false @@ -34,10 +34,7 @@ class Create<%= model_model_name.pluralize %> < ActiveRecord::Migration<%= migra # Load models from JSON say_with_time "Loading models from models.json" do RubyLLM.models.load_from_json! - model_class = '<%= model_model_name %>'.constantize - model_class.save_to_database - - "Loaded #{model_class.count} models" + <%= model_model_name %>.save_to_database 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 index e1205ed23..01cfe3a24 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,15 +1,15 @@ <%#- # Migration for creating tool_calls table with database-specific JSON handling -%> -class Create<%= tool_call_model_name.pluralize %> < ActiveRecord::Migration<%= migration_version %> +class Create<%= tool_call_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %> def change - create_table :<%= tool_call_model_name.tableize %> do |t| - t.references :<%= message_model_name.tableize.singularize %>, null: false, foreign_key: true + create_table :<%= tool_call_table_name %> do |t| + t.references :<%= message_table_name.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 :<%= tool_call_model_name.tableize %>, :tool_call_id, unique: true - add_index :<%= tool_call_model_name.tableize %>, :name + add_index :<%= tool_call_table_name %>, :tool_call_id, unique: true + add_index :<%= tool_call_table_name %>, :name end end diff --git a/lib/generators/ruby_llm/install_generator.rb b/lib/generators/ruby_llm/install_generator.rb deleted file mode 100644 index d89908028..000000000 --- a/lib/generators/ruby_llm/install_generator.rb +++ /dev/null @@ -1,217 +0,0 @@ -# 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__) - - argument :model_mappings, type: :array, default: [], banner: 'chat:ChatName message:MessageName ...' - - class_option :skip_active_storage, type: :boolean, default: false, - desc: 'Skip ActiveStorage installation and attachment setup' - - desc 'Creates models and migrations for RubyLLM Rails integration\n' \ - 'Usage: rails g ruby_llm:install [chat:ChatName] [message:MessageName] ...' - - 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 parse_model_mappings - @model_names = { - chat: 'Chat', - message: 'Message', - tool_call: 'ToolCall', - model: 'Model' - } - - model_mappings.each do |mapping| - if mapping.include?(':') - key, value = mapping.split(':', 2) - @model_names[key.to_sym] = value.classify - end - end - - @model_names - end - - %i[chat message tool_call model].each do |type| - define_method("#{type}_model_name") do - @model_names ||= parse_model_mappings - @model_names[type] - end - end - - def acts_as_chat_declaration - acts_as_chat_params = [] - messages_assoc = message_model_name.tableize.to_sym - model_assoc = model_model_name.underscore.to_sym - - if messages_assoc != :messages - acts_as_chat_params << "messages: :#{messages_assoc}" - if message_model_name != messages_assoc.to_s.classify - acts_as_chat_params << "message_class: '#{message_model_name}'" - end - end - - if model_assoc != :model - acts_as_chat_params << "model: :#{model_assoc}" - acts_as_chat_params << "model_class: '#{model_model_name}'" if model_model_name != model_assoc.to_s.classify - 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 - params = [] - - add_message_association_params(params, :chat, chat_model_name) - add_message_association_params(params, :tool_calls, tool_call_model_name, tableize: true) - add_message_association_params(params, :model, model_model_name) - - params.any? ? "acts_as_message #{params.join(', ')}" : 'acts_as_message' - end - - private - - def add_message_association_params(params, default_assoc, model_name, tableize: false) - assoc = tableize ? model_name.tableize.to_sym : model_name.underscore.to_sym - - return if assoc == default_assoc - - params << "#{default_assoc}: :#{assoc}" - expected_class = assoc.to_s.classify - params << "#{default_assoc.to_s.singularize}_class: '#{model_name}'" if model_name != expected_class - end - - public - - def acts_as_tool_call_declaration - acts_as_tool_call_params = [] - message_assoc = message_model_name.underscore.to_sym - - if message_assoc != :message - acts_as_tool_call_params << "message: :#{message_assoc}" - if message_model_name != message_assoc.to_s.classify - acts_as_tool_call_params << "message_class: '#{message_model_name}'" - end - 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 acts_as_model_declaration - acts_as_model_params = [] - chats_assoc = chat_model_name.tableize.to_sym - - if chats_assoc != :chats - acts_as_model_params << "chats: :#{chats_assoc}" - acts_as_model_params << "chat_class: '#{chat_model_name}'" if chat_model_name != chats_assoc.to_s.classify - end - - if acts_as_model_params.any? - "acts_as_model #{acts_as_model_params.join(', ')}" - else - 'acts_as_model' - end - end - - def create_migration_files - # Create migrations with timestamps to ensure proper order - # First create chats table - migration_template 'create_chats_migration.rb.tt', - "db/migrate/create_#{chat_model_name.tableize}.rb" - - # Then create messages table (must come before tool_calls due to foreign key) - sleep 1 # Ensure different timestamp - migration_template 'create_messages_migration.rb.tt', - "db/migrate/create_#{message_model_name.tableize}.rb" - - # Then create tool_calls table (references messages) - sleep 1 # Ensure different timestamp - migration_template 'create_tool_calls_migration.rb.tt', - "db/migrate/create_#{tool_call_model_name.tableize}.rb" - - # Create models table - sleep 1 # Ensure different timestamp - migration_template 'create_models_migration.rb.tt', - "db/migrate/create_#{model_model_name.tableize}.rb" - end - - def create_model_files - template 'chat_model.rb.tt', "app/models/#{chat_model_name.underscore}.rb" - template 'message_model.rb.tt', "app/models/#{message_model_name.underscore}.rb" - template 'tool_call_model.rb.tt', "app/models/#{tool_call_model_name.underscore}.rb" - - template 'model_model.rb.tt', "app/models/#{model_model_name.underscore}.rb" - end - - def create_initializer - template 'initializer.rb.tt', 'config/initializers/ruby_llm.rb' - end - - def install_active_storage - return if options[:skip_active_storage] - - say ' Installing ActiveStorage for file attachments...', :cyan - rails_command 'active_storage:install' - end - - def show_install_info - say "\n ✅ RubyLLM installed!", :green - - say ' ✅ ActiveStorage configured for file attachments support', :green unless options[:skip_active_storage] - - say "\n Next steps:", :yellow - say ' 1. Run: rails db:migrate' - say ' 2. Set your API keys in config/initializers/ruby_llm.rb' - - say " 3. Start chatting: #{chat_model_name}.create!(model: 'gpt-4.1-nano').ask('Hello!')" - - say "\n 🚀 Model registry is database-backed!", :cyan - say ' Models automatically load from the database' - say ' Pass model names as strings - RubyLLM handles the rest!' - say " Specify provider when needed: Chat.create!(model: 'gemini-2.5-flash', provider: 'vertexai')" - - if options[:skip_active_storage] - say "\n 📎 Note: ActiveStorage was skipped", :yellow - say ' File attachments won\'t work without ActiveStorage.' - say ' To enable later:' - say ' 1. Run: rails active_storage:install && rails db:migrate' - say " 2. Add to your #{message_model_name} model: has_many_attached :attachments" - end - - say "\n 📚 Documentation: https://rubyllm.com", :cyan - - say "\n ❤️ Love RubyLLM?", :magenta - say ' • ⭐ Star on GitHub: https://github.com/crmne/ruby_llm' - say ' • 🐦 Follow for updates: https://x.com/paolino' - say "\n" - end - end -end diff --git a/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt b/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt index bd6d7b1dd..a1763b6f4 100644 --- a/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +++ b/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt @@ -3,36 +3,44 @@ class MigrateToRubyLLMModelReferences < ActiveRecord::Migration<%= migration_ver model_class = <%= model_model_name %> chat_class = <%= chat_model_name %> message_class = <%= message_model_name %> +<% if @model_table_already_existed %> + # Load models from models.json if Model table already existed + say_with_time "Loading models from models.json" do + RubyLLM.models.load_from_json! + model_class.save_to_database + "Loaded #{model_class.count} models" + end +<% end %> # Then check for any models in existing data that aren't in models.json say_with_time "Checking for additional models in existing data" do - collect_and_create_models(chat_class, :<%= chat_model_name.tableize %>, model_class) - collect_and_create_models(message_class, :<%= message_model_name.tableize %>, model_class) + collect_and_create_models(chat_class, :<%= chat_table_name %>, model_class) + collect_and_create_models(message_class, :<%= message_table_name %>, model_class) model_class.count end # Migrate foreign keys - migrate_foreign_key(:<%= chat_model_name.tableize %>, chat_class, model_class, :<%= model_model_name.underscore %>) - migrate_foreign_key(:<%= message_model_name.tableize %>, message_class, model_class, :<%= model_model_name.underscore %>) + migrate_foreign_key(:<%= chat_table_name %>, chat_class, model_class, :<%= model_table_name.singularize %>) + migrate_foreign_key(:<%= message_table_name %>, message_class, model_class, :<%= model_table_name.singularize %>) end def down # Remove foreign key references - if column_exists?(:<%= message_model_name.tableize %>, :<%= model_model_name.underscore %>_id) - remove_reference :<%= message_model_name.tableize %>, :<%= model_model_name.underscore %>, foreign_key: true + if column_exists?(:<%= message_table_name %>, :<%= model_table_name.singularize %>_id) + remove_reference :<%= message_table_name %>, :<%= model_table_name.singularize %>, foreign_key: true end - if column_exists?(:<%= chat_model_name.tableize %>, :<%= model_model_name.underscore %>_id) - remove_reference :<%= chat_model_name.tableize %>, :<%= model_model_name.underscore %>, foreign_key: true + if column_exists?(:<%= chat_table_name %>, :<%= model_table_name.singularize %>_id) + remove_reference :<%= chat_table_name %>, :<%= model_table_name.singularize %>, foreign_key: true end # Restore original model_id string columns - if column_exists?(:<%= message_model_name.tableize %>, :model_id_string) - rename_column :<%= message_model_name.tableize %>, :model_id_string, :model_id + if column_exists?(:<%= message_table_name %>, :model_id_string) + rename_column :<%= message_table_name %>, :model_id_string, :model_id end - if column_exists?(:<%= chat_model_name.tableize %>, :model_id_string) - rename_column :<%= chat_model_name.tableize %>, :model_id_string, :model_id + if column_exists?(:<%= chat_table_name %>, :model_id_string) + rename_column :<%= chat_table_name %>, :model_id_string, :model_id end end @@ -134,4 +142,4 @@ class MigrateToRubyLLMModelReferences < ActiveRecord::Migration<%= migration_ver model_class.find_by(model_id: model_id) end end -end \ No newline at end of file +end diff --git a/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb b/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb new file mode 100644 index 000000000..55d1fedf6 --- /dev/null +++ b/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'rails/generators' +require 'rails/generators/active_record' +require_relative '../generator_helpers' + +module RubyLLM + class UpgradeToV17Generator < Rails::Generators::Base # rubocop:disable Style/Documentation + include Rails::Generators::Migration + include RubyLLM::GeneratorHelpers + + namespace 'ruby_llm:upgrade_to_v1_7' + source_root File.expand_path('templates', __dir__) + + # Override source_paths to include install templates + def self.source_paths + [ + File.expand_path('templates', __dir__), + File.expand_path('../install/templates', __dir__) + ] + end + + argument :model_mappings, type: :array, default: [], banner: 'chat:ChatName message:MessageName ...' + + desc 'Upgrades existing RubyLLM apps to v1.7 with new Rails-like API\n' \ + 'Usage: rails g ruby_llm:upgrade_to_v1_7 [chat:ChatName] [message:MessageName] ...' + + def self.next_migration_number(dirname) + ::ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + def create_migration_file + @model_table_already_existed = table_exists?(table_name_for(model_model_name)) + + # First check if models table exists, if not create it + unless @model_table_already_existed + migration_template 'create_models_migration.rb.tt', + "db/migrate/create_#{table_name_for(model_model_name)}.rb", + migration_version: migration_version, + model_model_name: model_model_name + + sleep 1 # Ensure different timestamp + end + + migration_template 'migration.rb.tt', + 'db/migrate/migrate_to_ruby_llm_model_references.rb', + migration_version: migration_version, + chat_model_name: chat_model_name, + message_model_name: message_model_name, + tool_call_model_name: tool_call_model_name, + model_model_name: model_model_name, + model_table_already_existed: @model_table_already_existed + end + + def create_model_file + create_namespace_modules + + template 'model_model.rb.tt', "app/models/#{model_model_name.underscore}.rb" + end + + def update_existing_models + update_model_acts_as(chat_model_name, 'acts_as_chat', acts_as_chat_declaration) + update_model_acts_as(message_model_name, 'acts_as_message', acts_as_message_declaration) + update_model_acts_as(tool_call_model_name, 'acts_as_tool_call', acts_as_tool_call_declaration) + end + + def update_initializer + initializer_path = 'config/initializers/ruby_llm.rb' + + unless File.exist?(initializer_path) + say_status :warning, 'No initializer found. Creating one...', :yellow + template 'initializer.rb.tt', initializer_path + return + end + + initializer_content = File.read(initializer_path) + + return if initializer_content.include?('config.use_new_acts_as') + + inject_into_file initializer_path, before: /^end/ do + lines = ["\n # Enable the new Rails-like API", ' config.use_new_acts_as = true'] + lines << " config.model_registry_class = \"#{model_model_name}\"" if model_model_name != 'Model' + lines << "\n" + lines.join("\n") + end + end + + def show_next_steps + say_status :success, 'Upgrade prepared!', :green + say <<~INSTRUCTIONS + + Next steps: + 1. Review the generated migrations + 2. Run: rails db:migrate + 3. Update your code to use the new API: #{chat_model_name}.create! now has the same signature as RubyLLM.chat + + ⚠️ If you get "undefined method 'acts_as_model'" during migration: + Add this to config/application.rb BEFORE your Application class: + + RubyLLM.configure do |config| + config.use_new_acts_as = true + end + + 📚 See the full migration guide: https://rubyllm.com/upgrading-to-1-7/ + + INSTRUCTIONS + end + + private + + def update_model_acts_as(model_name, old_acts_as, new_acts_as) + model_path = "app/models/#{model_name.underscore}.rb" + return unless File.exist?(Rails.root.join(model_path)) + + content = File.read(Rails.root.join(model_path)) + return unless content.match?(/^\s*#{old_acts_as}/) + + gsub_file model_path, /^\s*#{old_acts_as}.*$/, " #{new_acts_as}" + end + end +end diff --git a/lib/generators/ruby_llm/upgrade_to_v1_7_generator.rb b/lib/generators/ruby_llm/upgrade_to_v1_7_generator.rb deleted file mode 100644 index 363d5f6c9..000000000 --- a/lib/generators/ruby_llm/upgrade_to_v1_7_generator.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true - -require 'rails/generators' -require 'rails/generators/active_record' - -module RubyLLM - class UpgradeToV17Generator < Rails::Generators::Base # rubocop:disable Style/Documentation - include Rails::Generators::Migration - - namespace 'ruby_llm:upgrade_to_v1_7' - source_root File.expand_path('upgrade_to_v1_7/templates', __dir__) - - # Override source_paths to include install templates - def self.source_paths - [ - File.expand_path('upgrade_to_v1_7/templates', __dir__), - File.expand_path('install/templates', __dir__) - ] - end - - argument :model_mappings, type: :array, default: [], banner: 'chat:ChatName message:MessageName ...' - - desc 'Upgrades existing RubyLLM apps to v1.7 with new Rails-like API\n' \ - 'Usage: rails g ruby_llm:upgrade_to_v1_7 [chat:ChatName] [message:MessageName] ...' - - def self.next_migration_number(dirname) - ::ActiveRecord::Generators::Base.next_migration_number(dirname) - end - - def parse_model_mappings - @model_names = { - chat: 'Chat', - message: 'Message', - tool_call: 'ToolCall', - model: 'Model' - } - - model_mappings.each do |mapping| - if mapping.include?(':') - key, value = mapping.split(':', 2) - @model_names[key.to_sym] = value.classify - end - end - - @model_names - end - - %i[chat message tool_call model].each do |type| - define_method("#{type}_model_name") do - @model_names ||= parse_model_mappings - @model_names[type] - end - end - - def create_migration_file - # First check if models table exists, if not create it - unless table_exists?(model_model_name.tableize) - migration_template 'create_models_migration.rb.tt', - "db/migrate/create_#{model_model_name.tableize}.rb", - migration_version: migration_version, - model_model_name: model_model_name - - sleep 1 # Ensure different timestamp - end - - migration_template 'migration.rb.tt', - 'db/migrate/migrate_to_ruby_llm_model_references.rb', - migration_version: migration_version, - chat_model_name: chat_model_name, - message_model_name: message_model_name, - tool_call_model_name: tool_call_model_name, - model_model_name: model_model_name - end - - def create_model_file - # Check if Model file already exists - model_path = "app/models/#{model_model_name.underscore}.rb" - - if File.exist?(Rails.root.join(model_path)) - say_status :skip, model_path, :yellow - else - create_file model_path do - <<~RUBY - class #{model_model_name} < ApplicationRecord - #{acts_as_model_declaration} - end - RUBY - end - end - end - - def acts_as_model_declaration - acts_as_model_params = [] - chats_assoc = chat_model_name.tableize.to_sym - - if chats_assoc != :chats - acts_as_model_params << "chats: :#{chats_assoc}" - acts_as_model_params << "chat_class: '#{chat_model_name}'" if chat_model_name != chats_assoc.to_s.classify - end - - if acts_as_model_params.any? - "acts_as_model #{acts_as_model_params.join(', ')}" - else - 'acts_as_model' - end - end - - def update_initializer - initializer_content = File.read('config/initializers/ruby_llm.rb') - - unless initializer_content.include?('config.use_new_acts_as') - inject_into_file 'config/initializers/ruby_llm.rb', before: /^end/ do - lines = ["\n # Enable the new Rails-like API", ' config.use_new_acts_as = true'] - lines << " config.model_registry_class = \"#{model_model_name}\"" if model_model_name != 'Model' - lines << "\n" - lines.join("\n") - end - end - rescue Errno::ENOENT - say_status :error, 'config/initializers/ruby_llm.rb not found', :red - end - - def show_next_steps - say_status :success, 'Migration created!', :green - say <<~INSTRUCTIONS - - Next steps: - 1. Review the migration: db/migrate/*_migrate_to_ruby_llm_model_references.rb - 2. Run: rails db:migrate - 3. Update config/initializers/ruby_llm.rb as shown above - 4. Test your application thoroughly - - The migration will: - - Create the Models table if it doesn't exist - - Load all models from models.json - - Migrate your existing data to use foreign keys - - Preserve all existing data (string columns renamed to model_id_string) - - INSTRUCTIONS - end - - private - - def migration_version - "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" - end - - def table_exists?(table_name) - ::ActiveRecord::Base.connection.table_exists?(table_name) - rescue StandardError - false - end - - def postgresql? - ::ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') - rescue StandardError - false - end - end -end diff --git a/lib/ruby_llm/version.rb b/lib/ruby_llm/version.rb index baa72075a..492389d60 100644 --- a/lib/ruby_llm/version.rb +++ b/lib/ruby_llm/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RubyLLM - VERSION = '1.7.0' + VERSION = '1.7.1' end diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index 062a7c21b..e463bde24 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -9,12 +9,6 @@ require 'action_controller/railtie' Bundler.require(*Rails.groups) -require 'ruby_llm' - -# Configure RubyLLM to use Model registry for tests -RubyLLM.configure do |config| - config.model_registry_class = 'Model' -end module Dummy class Application < Rails::Application diff --git a/spec/lib/generators/ruby_llm/install_generator_spec.rb b/spec/lib/generators/ruby_llm/install_generator_spec.rb index 520bd6c79..df538e1c1 100644 --- a/spec/lib/generators/ruby_llm/install_generator_spec.rb +++ b/spec/lib/generators/ruby_llm/install_generator_spec.rb @@ -2,12 +2,12 @@ require 'rails_helper' require 'fileutils' -require 'generators/ruby_llm/install_generator' +require 'generators/ruby_llm/install/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') } + let(:generator_file) { File.join(__dir__, '../../../../lib/generators/ruby_llm/install/install_generator.rb') } describe 'migration templates' do let(:expected_migration_files) do @@ -29,11 +29,11 @@ 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 :<%= chat_model_name.tableize %>') + expect(chat_migration).to include('create_table :<%= chat_table_name %>') end it 'includes model reference' do - expect(chat_migration).to include('t.references :<%= model_model_name.tableize.singularize %>') + expect(chat_migration).to include('t.references :<%= model_table_name.singularize %>') end end @@ -41,11 +41,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 :<%= message_model_name.tableize %>') + expect(message_migration).to include('create_table :<%= message_table_name %>') end it 'includes chat reference' do - expect(message_migration).to include('t.references :<%= chat_model_name.tableize.singularize %>, null: false, foreign_key: true') # rubocop:disable Layout/LineLength + expect(message_migration).to include('t.references :<%= chat_table_name.singularize %>, null: false, foreign_key: true') # rubocop:disable Layout/LineLength end it 'includes role field' do @@ -61,7 +61,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_call_model_name.tableize %>') + expect(tool_call_migration).to include('create_table :<%= tool_call_table_name %>') end it 'includes tool_call_id field' do @@ -125,7 +125,7 @@ let(:models_migration) { File.read(File.join(template_dir, 'create_models_migration.rb.tt')) } it 'defines models table' do - expect(models_migration).to include('create_table :<%= model_model_name.tableize %>') + expect(models_migration).to include('create_table :<%= model_table_name %>') end it 'includes model_id field' do @@ -240,11 +240,11 @@ it 'creates migrations in correct order' do migration_section = generator_content[/def create_migration_files.*?\n end/m] - # Look for the model name references which are in the migration paths - chats_position = migration_section.index('chat_model_name') - messages_position = migration_section.index('message_model_name') - tool_calls_position = migration_section.index('tool_call_model_name') - models_position = migration_section.index('model_model_name') + # Look for the table name references which are in the migration paths + chats_position = migration_section.index('chat_table_name') + messages_position = migration_section.index('message_table_name') + tool_calls_position = migration_section.index('tool_call_table_name') + models_position = migration_section.index('model_table_name') expect(chats_position).not_to be_nil expect(messages_position).not_to be_nil @@ -264,31 +264,6 @@ 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 'uses global ActiveRecord constant' do - expect(generator_content).to include('::ActiveRecord::Base.connection.adapter_name') - end - - it 'detects PostgreSQL adapter' do - expect(generator_content).to include('.downcase.include?(\'postgresql\')') - end - - it 'includes rescue block for error handling' do - expect(generator_content).to include('rescue StandardError') - end - - it 'returns false on error' do - postgresql_method = generator_content[/def postgresql\?.*?end/m] - expect(postgresql_method).to include('false') - end - end - describe '#postgresql?' do subject(:generator) { described_class.new }