This post will kick off a blogpost series of typical features, that can be built with the OpenAI API and Rails. It’s way more powerful to interact with the API directly than with ChatGPT and a lot of interesting features can be built with the API.

The AI post feature

In this post I’ll start with a “simple” feature:

In a simple blogpost form with a title field and content textarea, there’s a button above the textarea that fills the textarea with a draft post based on the title. I will implement it with the OpenAI Chat API, StimulusJS and Server-Sent events.

Here’s a small video demonstration:

The basic setup

Let’s add the ruby-openai gem to our Gemfile to interact with the OpenAI API.

bundle add ruby-openai

Aftwards generate an API key here.

Next add the api key to our encrypted development credentials with rails credentials:edit --environment development. Paste your key with e.g. openai_api_key: <your-api-key> and close the editor.

Next I’m creating an initializer to configure the ruby-openai gem with the generated API key

OpenAI.configure do |config|
  config.access_token = Rails.application.credentials.openai_api_key!
end

Building a client to generate a post

Let’s start with the “Generate a text based on a title” part.

We make use of the Chat API to generate a blogpost. The chat endpoint expects us to send messages to it, and it will respond with a generated response. This is basically the API that ChatGPT uses. Whenever building things with the Chat API, I start by building a good set of prompts. I’ll start with a simple PORO that interacts with the API and can generate blogposts based on a given title.

class ContentWriter
  MODEL = 'gpt-3.5-turbo'

  def initialize
    @client = OpenAI::Client.new
  end

  def write_draft_post
    @client.chat(
      parameters: {
          model: MODEL,
          messages: [
            { role: "system", content: "You are a world class copywriter" },
            { role: "system", content: "Your output is always correctly formatted markdown" },
    )
  end
end

As a base model we’re using gpt-3.5-turbo and add two system messages to the messages array. The gpt-3.5-turbo provides cheap, fast and fairly accurate responses.

The messages array contains two system: prompts. You can think of system prompts as baseline configuration you want the model to have. For this demo, I basically only told it to be a copywriter and output markdown. I played around with this prompts and it worked fairly well for me.

We’re missing a user: prompt, which is basically the equivalent of the ChatGPT user chat message, so I’ll add a parameter to the write_draft_post method and add a third message to the messages array.

class ContentWriter
  MODEL = 'gpt-3.5-turbo'

  def initialize
    @client = OpenAI::Client.new
  end

  def write_draft_post(title)
    prompt = "Write a 1000 word blogpost about '#{title}'."
    @client.chat(
      parameters: {
          model: MODEL,
          messages: [
            { role: "system", content: "You are a world class copywriter" },
            { role: "system", content: "Your output is always correctly formatted markdown" },
            { role: "user", content: prompt },
    )
  end
end

I encourage you to try our ContentWriter in the rails console and see what the API generates.

$rails console

irb>ContentWriter.new.write_draft_post('10 reasons to eat pizza') 
irb>ContentWriter.new.write_draft_post('5 important cities in italy') 

If you’ve played around with it, you’ll notice, that even though we’re using the turbo variant of the model, it still takes quite some time until you receive your generated article. The model generates your article in small chunks (also called tokens). With our current configuration, the ContentWriter will first generate the complete article and then respond with the full article in a single response.

Like you’ve seen in the demo video, instead of waiting for the full article, we want to send each token as soon as it is available and basically build a similar experience to ChatGPTs interface. We’ll continue to do so by adding the stream: parameter to the #chat call.

The OpenAI API actually uses Server-Sent-Events and sends us each token as soon as it is available. The ruby-openai gem supports the streaming mode by passing a proc to the stream parameter.

I’ll add the parameter and pass an explicit proc to it:

class ContentWriter
  MODEL = 'gpt-3.5-turbo'

  def initialize
    @client = OpenAI::Client.new
  end

  def write_draft_post(title, &block)
    prompt = "Write a 1000 word blogpost about '#{title}'."
    @client.chat(
      parameters: {
          model: MODEL,
          messages: [
            { role: "system", content: "You are a world class copywriter" },
            { role: "system", content: "Your output is always correctly formatted markdown" },
            { role: "user", content: prompt },
          ]
      },
      stream: block
    )
  end
end

Now we can pass a block to write_draft_post and it will call our block for each token the API sends us. Neat!

Our Server-Sent Events endpoint

In the next step, let’s build the UI and live streaming part to the client. Similar to the OpenAI Chat API itself, I’m going to use Server-Sent-Events to livestream tokens to the browser.

My basic plan is to create a livestream endpoint, that streams the tokens to the browser and we’re reading the SSE with the EventSource JS API in a stimulus controller. Let’s build this endpoint next.

rails generate controller ai show

To enable SSE for a controller, I include the ActionController::Live concern.

class AiController < ApplicationController
  include ActionController::Live

  def show
  end
end

There’s currently one caveat with the Rack versions required for Rails 7.0, that “break” the real streaming of responses. Rails versions that upgraded to Rack 3 (probably 7.1) will fix this issue, but for now an easy workaround is to remove the Rack ETag middleware.

This is not as bad as it sounds, since the ETags produced by this middleware have probably never matched anything anyways before, because of the various types of tokens in your markup.

# application.rb
config.middleware.delete Rack::ETag # Rack::ETag breaks ActionController::Live

Let’s combine our ContentWriter class and the controller action and stream our API response in the controller action.

def show
  response.headers['Content-Type'] = 'text/event-stream'

  ContentWriter.new.write_draft_post(params[:title]) do |chunk|
    if token = chunk.dig("choices", 0, "delta", "content")
      write_token_event(token)
    end
  end
ensure
  response.stream.close
end

private

def write_token_event(data)
  response.stream.write "event: token\n"
  response.stream.write "data: #{CGI.escape(data)}\n\n"
end

We need to explicitly set the mime type manually to text/event-stream in streamed actions.

Now we’re calling our ContentWriter#write_draft_post with a :title parameter, and for each API response chunk we write a specially formatted response string with event: and data: keys, so we can use the EventSource API in the user’s browser.

For this demo, we only care about the value in "choices, 0, delta, content", so we’re pulling the token out with dig.

As the documentation for ActionController::Live recommends, we’re adding an ensure where we close the stream response to avoid having dangling unclosed streams.

We also escape everything that we put in the data: section of our chunk, since we sometimes receive "\n" as a token from OpenAI, whenever the model wants to add a newline, which messes with our streaming format.

The streamed Chat API has one special case for the last chunk it sends: It will have a chunk.dig('choices', 0, 'finish_reason') == "stop" signalling to us, that we have received the last response chunk. Even though it’s the last chunk under normal circumstances, we’ll send a special token to our clients that signals, that the response is done with streaming. We also close the response stream, by breaking out of our write_draft_post block and therefore closing the response in our ensure block.

This is what our action looks like in the end. It’s not the cleanest code we’ve ever written, there are some error conditions we did not handle, but it get’s the job done for now:

def show
  response.headers['Content-Type'] = 'text/event-stream'

  ContentWriter.new.write_draft_post(params[:title]) do |chunk|
    if chunk.dig('choices', 0, 'finish_reason') == 'stop'
      write_token_event('[DONE]')
      break
    end

    if token = chunk.dig("choices", 0, "delta", "content")
      write_token_event(token)
    end
  end
ensure
  response.stream.close
end

We’ve got the two out of three parts done, the frontend part is still missing.

Implementing a Stimulus Controller for SSEs

Let’s add a simple scaffold for a post with:

$rails generate scaffold post title content:text

And let’s modify our _form.html.erb and add a button that calls our API. I stripped large parts of the template and only added some notable markup

<%= form_with(model: post, class: "w-full", data: { controller: 'ai' }) do |form| %>

  ...
  <%= form.text_field :title, class: 'w-full', data: { ai_target: "title" } %>
  ...
  <%= form.label :content, class: 'block font-semibold' %>
  <%= button_tag type: :button, data: { ai_target: 'button', action: "ai#generateWithAI" }, class: '... disabled:bg-gray-600' do %>
    <svg>some cool svg icon</svg>
    <span class="whitespace-pre-wrap" data-ai-target="text">Generate draft with AI</span>
  <% end %>
  <%= form.text_area :content, rows: 15, data: { ai_target: 'textarea' }, class: 'resize-y w-full disabled:bg-gray-200' %>
  ...

Important things I changed in the form markup:

  • I added an ai_controller.js stimulus controller to the form element
  • I added a stimulus target for the title input field, textarea and the ai button target
  • I added disabled:bg-gray-... tailwind classes to style disabled elements without js
  • An stimulus action generateWithAI, that get’s called when we click the ai button.

Let’s look at the stimulus controller next and how it implements the streaming feature.

import { Controller } from "@hotwired/stimulus"


export default class extends Controller {
  static targets = ["textarea", "button", "title"]

  generateWithAI() {
    this.textareaTarget.disabled = true
    this.buttonTarget.disabled = true
    const eventSource = new EventSource(`/ai?title=${this.titleTarget.value}`)

    eventSource.addEventListener('token', ({ data }) => {
      if(data === '[DONE]') {
        this.textareaTarget.disabled = false
        this.buttonTarget.disabled = false
        eventSource.close()
      } else {
        this.textareaTarget.value = this.textareaTarget.value + decodeURIComponent(data)
        this.textareaTarget.scrollTop = this.textareaTarget.scrollHeight
      }
    })
  }
}

When calling generateWithAI, we disable the textarea and the ai button. This avoids double clicking the button and also gives us the ability to add some disabled styling, indicating that our JS is working.

We then use the EventSource JS API and connect with our Rails endpoint with the the title parameter, that we pass to our ContentWriter via params[:title].

This opens a connection to our live streaming action and starts the token generation. We then add eventlisteners for the 'token' event, which we specified with type: token in our controller action.

This eventlistener calls our function for each chunk received, and we have access to our tokens in the { data } property. If we receive our special marker [DONE], we’re closing the SSE-stream in the browser, to avoid having a connection open for longer than necessary.

If we receive any other data than our marker [DONE], we unescape our sent token (e.g. for "\n") before appending it to the existing value in the textarea. To keep the textarea scrolled to the bottom and the appended tokens visible, we’re setting the scrollTop property to scrollHeight of the textarea.

That’s it, now the streamed API response will be directly streamed into the textarea 🔥.

A next step could be to use the redcarpet gem to transform the generated markdown content and render a nicely formatted blogpost.

Final thoughts

There are some things I omitted for this post, but you should probably be handled in your implementation:

  • Add Rack::Timeout to kill long running connections that never got closed. The heroku docs have a good resource on it (Request timeout)
  • Handle some common exceptions in the Rails controller, e.g. ActionController::Live::ClientDisconnected
  • Add a eventSource.addEventListener('error', fn) to handle errors on the client side
  • Write some tests 🙈

I’ll write a post about detailed error handling and testing the OpenAI APIs in the future.

This is the first post in a series of OpenAI integrations with Rails, please email me if you have any questions, found bugs or if you have ideas for more features I could showcase. The next post will be about the Transcription API and how to integrate it with Activestorage, so that uploaded Audiofiles are being transcribed automatically.

Stay tuned 🤗