diff --git a/.gitignore b/.gitignore index b04a8c8..40b2efc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ /pkg/ /spec/reports/ /tmp/ +.ruby-version # rspec failure tracking .rspec_status +**/.DS_Store diff --git a/README.md b/README.md index 5c2981a..0cf2989 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,18 @@ prompt.render(adjective: "funny", subject: "elephants") # => "Tell me a funny joke about elephants." ``` -### Assistant Generator - adds assistant capabilities to your ActiveRecord model +### Assistant Generator - adds Langchain::Assistant capabilities to your Rails app + +This generator adds Langchain::Assistant-related ActiveRecord models, migrations, controllers, views and route to your Rails app. You can start creating assistants and chatting with them in immediately. + +```bash +rails generate langchainrb_rails:assistant --llm=openai +``` + +Available `--llm` options: `anthropic`, `cohere`, `google_palm`, `google_gemini`, `google_vertex_ai`, `hugging_face`, `llama_cpp`, `mistral_ai`, `ollama`, `openai`, and `replicate`. The selected LLM will be used to generate completions. + +To remove the generated files, run: + ```bash -rails generate langchainrb_rails:assistant -``` \ No newline at end of file +rails destroy langchainrb_rails:assistant +``` diff --git a/lib/langchainrb_overrides/assistant.rb b/lib/langchainrb_overrides/assistant.rb index a611b6a..20e521d 100644 --- a/lib/langchainrb_overrides/assistant.rb +++ b/lib/langchainrb_overrides/assistant.rb @@ -58,7 +58,8 @@ def load(id) llm: ar_assistant.llm, tools: tools, instructions: ar_assistant.instructions, - tool_choice: ar_assistant.tool_choice + # Default to auto to match the behavior of the original Langchain::Assistant + tool_choice: ar_assistant.tool_choice || "auto" ) ar_assistant.messages.each do |ar_message| diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/assistant_generator.rb b/lib/langchainrb_rails/generators/langchainrb_rails/assistant_generator.rb index 8d9b213..e98da10 100644 --- a/lib/langchainrb_rails/generators/langchainrb_rails/assistant_generator.rb +++ b/lib/langchainrb_rails/generators/langchainrb_rails/assistant_generator.rb @@ -51,12 +51,60 @@ def migration_version "[#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}]" end + def create_controller_file + template "assistant/controllers/assistants_controller.rb", "app/controllers/assistants_controller.rb" + end + + def create_view_files + template "assistant/views/_message.html.erb", "app/views/assistants/_message.html.erb" + template "assistant/views/_message_form.html.erb", "app/views/assistants/_message_form.html.erb" + template "assistant/views/chat.turbo_stream.erb", "app/views/assistants/chat.turbo_stream.erb" + template "assistant/views/edit.html.erb", "app/views/assistants/edit.html.erb" + template "assistant/views/index.html.erb", "app/views/assistants/index.html.erb" + template "assistant/views/new.html.erb", "app/views/assistants/new.html.erb" + template "assistant/views/show.html.erb", "app/views/assistants/show.html.erb" + end + + def add_routes + route <<~EOS + resources :assistants do + member do + post 'chat' + end + end + EOS + end + + # TODO: Copy stylesheet into app/assets/stylesheets or whatever the host app uses + def copy_stylesheets + template "assistant/stylesheets/chat.css", "app/assets/stylesheets/chat.css" + end + # TODO: Depending on the LLM provider, we may need to add additional gems - # def add_to_gemfile - # end + def add_to_gemfile + gem_name = "turbo-rails" + + if gem_exists?(gem_name) + say_status :skipped, "#{gem_name} already exists in Gemfile" + else + inside Rails.root do + run "bundle add #{gem_name}" + end + end + end + + def post_install_message + say "1. Set an environment variable ENV['#{llm.upcase}_API_KEY'] for your #{llm_class}." + say "2. Run `rails db:migrate` to apply the database migrations to create the assistants and messages tables." + say "3. Start your Rails server and navigate to `/assistants` to create your first assistant!" + end private + def gem_exists?(gem_name) + File.read(Rails.root.join("Gemfile")).include?(gem_name) + end + # @return [String] LLM provider to use def llm options["llm"] diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/controllers/assistants_controller.rb b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/controllers/assistants_controller.rb new file mode 100644 index 0000000..654139b --- /dev/null +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/controllers/assistants_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class AssistantsController < ApplicationController + before_action :set_assistant, only: [:show, :edit, :update, :chat, :destroy] + + def index + @assistants = Assistant.all + end + + def new + @assistant = Assistant.new + end + + def create + @assistant = Assistant.new(assistant_params) + if @assistant.save + redirect_to @assistant, notice: "Assistant was successfully created." + else + render :new + end + end + + def show + @assistants = Assistant.all + @assistant = Assistant.find(params[:id]) + @messages = @assistant.messages + @message = Message.new + end + + def edit + end + + def update + if @assistant.update(assistant_params) + redirect_to @assistant, notice: "Assistant was successfully updated." + else + render :edit + end + end + + def chat + @assistant = Assistant.find(params[:id]) + @message = @assistant.messages.create(role: "user", content: params[:message][:content]) + + langchain_assistant = Langchain::Assistant.load(@assistant.id) + messages = langchain_assistant.add_message_and_run!(content: params[:message][:content]) + response = messages.last + + @response = @assistant.messages.create(role: "assistant", content: response.content) + + respond_to do |format| + format.turbo_stream + end + end + + def destroy + @assistant.destroy + redirect_to assistants_path, notice: "Assistant was successfully deleted." + end + + private + + def set_assistant + @assistant = Assistant.find(params[:id]) + end + + def assistant_params + params.require(:assistant).permit(:name, :instructions, :tool_choice) + end +end diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_assistants.rb.tt b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_assistants.rb.tt index a9d7fb0..fd63f68 100644 --- a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_assistants.rb.tt +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_assistants.rb.tt @@ -1,9 +1,10 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> def change create_table :assistants do |t| + t.string :name, null: false t.string :instructions t.string :tool_choice - t.json :tools + t.json :tools, default: [] t.timestamps end end diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_messages.rb.tt b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_messages.rb.tt index 6322378..1d64484 100644 --- a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_messages.rb.tt +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/migrations/create_messages.rb.tt @@ -2,9 +2,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version def change create_table :messages do |t| t.references :assistant, foreign_key: true - t.string :role + t.string :role, null: false t.text :content - t.json :tool_calls + t.json :tool_calls, default: [] t.string :tool_call_id t.timestamps end diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/assistant.rb.tt b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/assistant.rb.tt index 4cd69f5..57c1e68 100644 --- a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/assistant.rb.tt +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/assistant.rb.tt @@ -1,7 +1,11 @@ # frozen_string_literal: true class Assistant < ActiveRecord::Base - has_many :messages +has_many :messages, dependent: :destroy + + validates :name, presence: true + + # TODO: Validate tool_choice def llm <%= llm_class %>.new(api_key: ENV["<%= llm.upcase %>_API_KEY"]) diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/message.rb.tt b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/message.rb.tt index 69c20e8..1a84614 100644 --- a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/message.rb.tt +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/models/message.rb.tt @@ -2,4 +2,6 @@ class Message < ActiveRecord::Base belongs_to :assistant + + validates :role, presence: true end diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/stylesheets/chat.css b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/stylesheets/chat.css new file mode 100644 index 0000000..ebc3291 --- /dev/null +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/stylesheets/chat.css @@ -0,0 +1,336 @@ +body, html { + margin: 0; + padding: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; +} + +.chat-interface { + display: flex; + height: 100vh; +} + +.sidebar { + width: 260px; + background-color: #f7f7f8; + color: #202123; + display: flex; + flex-direction: column; + border-right: 1px solid #e5e5e5; +} + +.sidebar-header { + padding: 15px; + border-bottom: 1px solid #e5e5e5; +} + +.sidebar-header h1 { + margin: 0; + font-size: 20px; + color: #202123; +} + +.assistants-nav { + padding: 15px; +} + +.assistants-nav h2 { + font-size: 12px; + text-transform: uppercase; + margin-bottom: 15px; + color: #6e6e80; +} + +.assistants-list { + list-style-type: none; + padding: 0; + margin: 0; +} + +.assistant-item { + margin-bottom: 5px; +} + +.assistant-link { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + color: #202123; + text-decoration: none; + border-radius: 5px; + transition: background-color 0.3s ease; +} + +.assistant-link:hover { + background-color: #ececf1; +} + +.assistant-item.active .assistant-link { + background-color: #e5e5e5; +} + +.ellipsis { + color: #6e6e80; +} + +.chat-main { + flex-grow: 1; + display: flex; + flex-direction: column; + background-color: #ffffff; +} + +.chat-header { + padding: 15px; + background-color: #ffffff; + border-bottom: 1px solid #e5e5e5; +} + +.chat-header h2 { + margin: 0; + color: #202123; + font-size: 16px; +} + +.chat-messages { + flex-grow: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; +} + +.message { + max-width: 70%; + margin-bottom: 20px; + line-height: 1.5; + padding: 10px; + border-radius: 5px; +} + +.message.user { + align-self: flex-end; + background-color: #2194FB; + color: #ffffff; + border-bottom-right-radius: 0; +} + +.message.assistant { + align-self: flex-start; + background-color: #f7f7f8; + color: #202123; + border-bottom-left-radius: 0; +} + +.chat-footer { + padding: 15px; + background-color: #ffffff; + border-top: 1px solid #e5e5e5; +} + +.chat-form { + display: flex; + align-items: center; +} + +.chat-input { + flex-grow: 1; + padding: 10px; + border: 1px solid #e5e5e5; + border-radius: 5px; + background-color: #ffffff; + color: #202123; + font-size: 14px; + resize: none; +} + +.chat-input:focus { + outline: none; + border-color: #2194FB; +} + +.send-button { + background: none; + border: none; + cursor: pointer; + padding: 5px; + margin-left: 10px; +} + +.send-icon { + width: 20px; + height: 20px; + color: #2194FB; +} + +.send-button:hover .send-icon { + color: #0087fc; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.btn { + display: inline-block; + padding: 10px 20px; + background-color: #2194FB; + color: #ffffff; + text-decoration: none; + border-radius: 5px; + transition: background-color 0.3s ease; +} + +.btn:hover { + background-color: #0087fc; +} + +/* Index view */ +.assistants-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.assistants-list { + background-color: #ffffff; + border-radius: 5px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.assistant-item { + border-bottom: 1px solid #e5e5e5; +} + +.assistant-item:last-child { + border-bottom: none; +} + +.assistant-link { + display: block; + padding: 15px; + color: #202123; + text-decoration: none; +} + +.assistant-link:hover { + background-color: #f7f7f8; +} + +.assistant-name { + font-weight: bold; + color: #2194FB; +} + +.assistant-tool { + display: inline-block; + padding: 2px 8px; + background-color: #e5e5e5; + border-radius: 10px; + font-size: 12px; +} + +.assistant-instructions { + margin-top: 5px; + color: #6e6e80; + font-size: 14px; +} + +/* New and Edit views */ +.form-container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 30px; + border-radius: 5px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.form-title { + margin-bottom: 20px; + color: #202123; +} + +.form-group { + margin-bottom: 20px; +} + +.form-label { + display: block; + margin-bottom: 5px; + color: #6e6e80; +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: 10px; + border: 1px solid #e5e5e5; + border-radius: 5px; + font-size: 14px; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: #2194FB; +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-actions { + display: flex; + justify-content: space-between; + align-items: center; +} + +.form-submit { + background-color: #2194FB; + color: #ffffff; + border: none; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.form-submit:hover { + background-color: #0087fc; +} + +.form-cancel { + color: #6e6e80; + text-decoration: none; +} + +.form-cancel:hover { + text-decoration: underline; +} + +/* Error messages */ +.error-messages { + background-color: #ffebee; + border: 1px solid #ffcdd2; + color: #b71c1c; + padding: 10px; + border-radius: 5px; + margin-bottom: 20px; +} + +.error-messages h2 { + font-size: 16px; + margin-bottom: 10px; +} + +.error-messages ul { + padding-left: 20px; +} diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/_message.html.erb b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/_message.html.erb new file mode 100644 index 0000000..64c741d --- /dev/null +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/_message.html.erb @@ -0,0 +1,5 @@ +
diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/_message_form.html.erb b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/_message_form.html.erb new file mode 100644 index 0000000..e0e784e --- /dev/null +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/_message_form.html.erb @@ -0,0 +1,4 @@ +<%%= form_with(model: [@assistant, Message.new], url: chat_assistant_path(@assistant), method: :post, class: "chat-form") do |form| %> + <%%= form.text_field :content, class: "chat-input", placeholder: "Type your message..." %> + <%%= form.submit 'Send', class: "chat-submit" %> +<%% end %> diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/chat.turbo_stream.erb b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/chat.turbo_stream.erb new file mode 100644 index 0000000..6930e94 --- /dev/null +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/chat.turbo_stream.erb @@ -0,0 +1,5 @@ +<%%= turbo_stream.append "chat-messages", partial: "assistants/message", locals: { message: @message } %> +<%%= turbo_stream.append "chat-messages", partial: "assistants/message", locals: { message: @response } %> +<%%= turbo_stream.replace "new_message" do %> + <%%= render "assistants/message_form" %> +<%% end %> diff --git a/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/edit.html.erb b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/edit.html.erb new file mode 100644 index 0000000..958fa68 --- /dev/null +++ b/lib/langchainrb_rails/generators/langchainrb_rails/templates/assistant/views/edit.html.erb @@ -0,0 +1,38 @@ +