Nell’episodio 1 abbiamo installato RubyLLM e fatto una singola chiamata da Ruby puro. Questa volta lo colleghiamo a una vera app Rails: un form dove scrivi un messaggio e ricevi una risposta dal modello. Nessun database, nessuna cronologia della conversazione ancora — quella è l’episodio 3. L’obiettivo qui è solo vedere RubyLLM funzionare dentro un vero ciclo richiesta/risposta di Rails.

Il codice completo di questo episodio è su GitHub, taggato episode-2, in un nuovo repo di accompagnamento — ai-with-ruby-demo — che crescerà a ogni post di questa serie.

Configurazione dell’app

Una nuova app Rails 8 con Tailwind CSS:

rails new ai-with-ruby-demo --css tailwind

Due gemme nel Gemfile:

gem "ruby_llm"

group :development do
  gem "dotenv-rails"
end

ruby_llm è la libreria vera e propria. dotenv-rails carica un file .env dentro ENV in development — Rails non lo fa da solo.

Tenere la API key fuori da git

Il .gitignore di default di Rails 8 esclude già .env*, quindi un file .env con la tua chiave reale non viene mai committato. Committiamo invece un .env.example, come modello per chi clona il repo:

OPENAI_API_KEY=sk-your-key-here

Copialo in locale e inserisci la tua chiave reale:

cp .env.example .env

Configurare RubyLLM

I file dentro config/initializers/ vengono eseguiti una sola volta, all’avvio, prima che qualsiasi richiesta venga gestita — il posto giusto per configurare una gemma come questa:

# config/initializers/ruby_llm.rb
RubyLLM.configure do |config|
  config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
end

Rotte e controller

Un resource (singolare) è adatto qui — non c’è una lista di chat da elencare o una specifica da recuperare per ID, solo un unico form:

# config/routes.rb
resource :chat, only: [:new, :create]
root "chats#new"
# app/controllers/chats_controller.rb
class ChatsController < ApplicationController
  def new
  end

  def create
    @message = params[:message]
    chat = RubyLLM.chat
    @response = chat.ask(@message).content
    render :new
  end
end

RubyLLM.chat crea una nuova sessione di chat in memoria — nulla viene salvato da nessuna parte, ed è proprio per questo che la conversazione sparisce con un refresh, per ora. .ask invia il messaggio e blocca l’esecuzione finché il modello non risponde; .content è il testo della risposta.

La view

<%= form_with url: chat_path, method: :post, data: { turbo: false } do %>
  <textarea name="message"><%= @message %></textarea>
  <button type="submit">Send</button>
<% end %>

<% if @response %>
  <p><%= @response %></p>
<% end %>

Il problema con Turbo

La prima versione di questo form non aveva data: { turbo: false }, e inviarlo non faceva… nulla di visibile. I log del server mostravano un pulito 200 OK, ma la pagina non si aggiornava mai.

La causa: Rails 8 include di default Turbo Drive, che intercetta ogni invio di form e lo manda come richiesta in background, aspettandosi o un redirect o una risposta nel formato Turbo Stream. Il nostro controller renderizzava semplice HTML — Turbo lo riceveva ma non sapeva cosa farci, quindi non faceva nulla silenziosamente.

data: { turbo: false } dice a Turbo di lasciare stare questo form specifico e inviarlo alla vecchia maniera: un vero caricamento di pagina. È la soluzione giusta per ora. Quando arriveremo alle risposte in streaming più avanti in questa serie, vorremo davvero usare Turbo Streams — ma per una prima versione “funziona o no”, disabilitarlo è la strada più semplice.

Cosa viene dopo

L’episodio 3 aggiunge la persistenza: modelli Conversation e Message, così la cronologia della chat sopravvive a un refresh della pagina invece di vivere solo dentro una singola richiesta.