🕌

【Rails × SSE】AIレスポンスをリアルタイムで表示するアプリ開発ログ

に公開
2025/12/21

こんにちは

プログラミングスクールで Ruby on Rails を学習している、りゅうと言うものです。

卒業制作でWebアプリを作っている中で、AIからの返答をリアルタイムで表示できるようにしたので、その備忘録になります。

⚠️ 注意

プログラミング初心者のため、誤りがあるかもしれません。
また、環境によっては同じ手順でもうまくいかない場合があります。

もしお気づきの点があれば、コメントなどで教えていただけると嬉しいです!

🧩 使用環境(バージョン情報)

ツール バージョン
Ruby 3.2.3
Rails 7.2.2.2
PostgreSQL 17.6
開発環境 Docker

フロー

重要な理解の要素は2つ

  1. SSE接続
  2. OpenAI API の streaming(その中身がのchunk / delta)

SSE とは、通常の HTTP/1.1 を使いながら、サーバーがクライアントへ複数回にわたってレスポンスを push できる単方向ストリーミング技術です。WebSocket より軽量で、EventSource を使うだけで簡単に実装できるメリットがあります。

OpenAI の streaming モードでは、レスポンスを一度に返すのではなく、chunk と呼ばれる小さなデータ単位で段階的に返します。chunk の中には delta(増分テキスト)が入っており、これをサーバー側で buffer に蓄積しながら処理します。

実際に5つのコードを見ていく。

config/routes.rb
namespace :ai do
    post :generate_words,   to: "words#generate"
    get :generate_proverb, to: "proverbs#generate"
end
app/views/proverbs/_form.html.erb
<%= form_with model: proverb, class: "w-full flex justify-center" do |f| %>
  <div class="w-full max-w-3xl bg-white/95 rounded-3xl shadow-2xl p-8 md:p-12 space-y-10"
       data-controller="ai">
    <div class="space-y-2 text-center">
      <h2 class="text-3xl md:text-4xl font-bold text-slate-900">ことわざを投稿する</h2>
    </div>

    <section class="space-y-6">
      <header class="text-left">
        <p class="text-xs font-semibold text-slate-500 uppercase tracking-[0.4em]">STEP 1</p>
        <h3 class="text-xl font-semibold text-slate-800">組み合わせる 2 つの言葉</h3>
      </header>

      <div class="grid gap-4 lg:grid-cols-[1fr_auto_1fr] items-center">
        <div class="space-y-2">
          <label for="proverb_word1" class="text-sm font-semibold text-slate-600">単語 1</label>
          <%= f.text_field :word1,
                           placeholder: "例:居酒屋",
                           class: "w-full py-3 px-4 rounded-2xl border border-slate-200 bg-slate-50 text-center font-semibold focus:border-rose-400 focus:ring-rose-200",
                           id: "proverb_word1" %>
        </div>

        <div class="flex justify-center items-center h-full">
          <span class="text-3xl font-black text-rose-400">✕</span>
        </div>

        <div class="space-y-2">
          <label for="proverb_word2" class="text-sm font-semibold text-slate-600">単語 2</label>
          <%= f.text_field :word2,
                           placeholder: "例:医者",
                           class: "w-full py-3 px-4 rounded-2xl border border-slate-200 bg-slate-50 text-center font-semibold focus:border-rose-400 focus:ring-rose-200",
                           id: "proverb_word2" %>
        </div>
      </div>

      <button type="button"
              data-action="click->ai#generateWords"
              class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-fuchsia-500 hover:bg-fuchsia-600 text-white font-semibold py-3 px-6 rounded-2xl shadow-lg transition">
        <span>言葉をAIに作ってもらう</span>
      </button>
    </section>

    <section class="space-y-6">

      <header class="text-left">
        <p class="text-xs font-semibold text-slate-500 uppercase tracking-[0.4em]">STEP 2</p>
        <h3 class="text-xl font-semibold text-slate-800">ことわざの内容</h3>
      </header>

      <div class="space-y-4">
        <div class="space-y-2">
          <label class="text-sm font-semibold text-slate-600">タイトル</label>
          <%= f.text_field :title,
                           placeholder: "例:居酒屋に医者いらず",
                           class: "w-full border border-slate-200 rounded-2xl py-3 px-6 text-sm sm:text-center sm:text-lg sm:font-semibold focus:border-rose-400 focus:ring-rose-200" %>
        </div>

        <div class="space-y-2">
          <label class="text-sm font-semibold text-slate-600">意味</label>
          <%= f.text_area  :meaning,
                           rows: 3,
                           placeholder: "例:堅苦しい忠告や…",
                           class: "w-full border border-slate-200 rounded-2xl py-3 px-4 text-sm focus:border-rose-400 focus:ring-rose-200" %>
        </div>

        <div class="space-y-2">
          <label class="text-sm font-semibold text-slate-600">用例</label>
          <%= f.text_area  :example,
                           rows: 3,
                           placeholder: "例:友達同士の飲み会で…",
                           class: "w-full border border-slate-200 rounded-2xl py-3 px-4 text-sm focus:border-rose-400 focus:ring-rose-200" %>
        </div>
      </div>

      <button type="button"
              data-action="click->ai#generateProverb"
              class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-fuchsia-500 hover:bg-fuchsia-600 text-white font-semibold py-3 px-6 rounded-2xl shadow-lg transition">
        <span>ことわざ、意味、用例をAIに作ってもらう</span>
      </button>
    </section>

    <%= f.hidden_field :status, value: "completed" %>

    <div class="flex flex-col md:flex-row gap-3 md:items-center md:justify-between pt-4">
      <p class="text-xs text-slate-500">すべての項目を入力してから投稿ボタンを押してください。</p>
      <%= f.submit "投稿",
                   class: "w-full md:w-40 bg-blue-500 hover:bg-blue-600 text-white font-semibold py-3 rounded-2xl shadow-lg transition" %>
    </div>
  </div>
<% end %>
app/javascript/controllers/ai_controller.js
import { Controller } from "@hotwired/stimulus"

一部抜粋

  // ことわざ/意味/用例の生成
  generateProverb(event) {
    event.preventDefault()
    const btn = event.currentTarget
    this.setLoading(btn, true, "考え中…")

    const word1 = document.querySelector("#proverb_word1")?.value || ""
    const word2 = document.querySelector("#proverb_word2")?.value || ""

    if (!word1 || !word2) {
      alert("言葉が入力されていません。2つの言葉を入力してください。")
      this.setLoading(btn, false)
      return
    }

    // 対象フォームを取得 & 中身をリセット
    const titleInput   = document.querySelector("#proverb_title")
    const meaningInput = document.querySelector("#proverb_meaning")
    const exampleInput = document.querySelector("#proverb_example")

    if (titleInput)   titleInput.value   = ""
    if (meaningInput) meaningInput.value = ""
    if (exampleInput) exampleInput.value = ""

    const url = `/ai/generate_proverb?word1=${encodeURIComponent(word1)}&word2=${encodeURIComponent(word2)}`
    const es  = new EventSource(url)

    es.onmessage = (event) => {
      const data = JSON.parse(event.data)

      if (data.event === "chunk") {
        // その時点までの全文で上書き
        if (titleInput)   titleInput.value   = data.title || ""
        if (meaningInput) meaningInput.value = data.meaning || ""
        if (exampleInput) exampleInput.value = data.example || ""
      } else if (data.event === "final") {
        // すでに form には全部入っている前提なので完了処理だけ
        this.setLoading(btn, false)
        es.close()
      }
    }

    es.onerror = () => {
      es.close()
      this.setLoading(btn, false)
      alert("AI生成中にエラーが発生しました。")
    }
  }
}
app/services/ai/proverbs_controller.rb
class Ai::ProverbsController < ApplicationController
  include ActionController::Live

  def generate
    response.headers["Content-Type"] = "text/event-stream"
    word1 = params[:word1]
    word2 = params[:word2]

    Ai::ProverbsGenerator.stream(word1: word1, word2: word2) do |event_type, payload|
      case event_type
      when :chunk
        # payloadにはsections(ハッシュ)が入る
        # { title: "...", meaning: "...", example: "..." }
        data = {
          event:   "chunk",
    title: payload[:title],
    meaning: payload[:meaning],
    example: payload[:example]
        }
        response.stream.write("data: #{data.to_json}\n\n")

      when :final
        result = payload

        # 最終の全文をブラウザに送る
        data = {
          event: "final",
          result: {
            title:   result[:title],
            meaning: result[:meaning],
            example: result[:example]
          }
        }
        response.stream.write("data: #{data.to_json}\n\n")
      end
    end
  ensure
    response.stream.close
  end
end
app/services/ai/proverbs_generator.rb
module Ai
  class ProverbsGenerator
    def self.build_prompt(word1:, word2:)
      <<~PROMPT
        以下の条件で、日本語の新しいことわざを作ってください。

        - 「#{word1}」と「#{word2}」の2つの言葉を必ず使う。
        - 現実味のある内容で、意味が理解しやすいものにする。
        - 少しユーモラスでも良いが、意味が通じることを重視する。
        - 実際にありそうな日本のことわざ風の表現にする。
        - 難しすぎる言葉や、文法的におかしい言い回しは避ける。

        出力形式は必ず次の形にしてください(JSONではなく、この区切りを含むテキスト):

        [title]
        ここにことわざ本体

        [meaning]
        ここに意味

        [example]
        ここに用例
      PROMPT
    end

    # チャンクごとに現在の全文を送り、すべて生成し終わったら最終結果(ハッシュ)を返す
    def self.stream(word1:, word2:)
      client  = OpenAI::Client.new
      prompt  = build_prompt(word1: word1, word2: word2)
      buffer  = +""

      client.chat(
        parameters: {
          model: "gpt-4o-mini",
          messages: [ { role: "user", content: prompt } ],
          temperature: 1.0,
          stream: proc do |chunk, _bytes|
            delta = chunk.dig("choices", 0, "delta", "content")
            next if delta.nil?

            buffer << delta

            sections = parse_sections(buffer)
            # 現在までに生成されたテキスト(まだ途中)を送る
            yield :chunk, sections if block_given?
          end
        }
      )
      # 全てのテキストを送る
      final_sections = parse_sections(buffer)
      yield :final, final_sections if block_given?
      final_sections
    end

    # [title] / [meaning] / [example] で区切られたテキストをパースしてハッシュで返す
    def self.parse_sections(text)
      title = meaning = example = ""

      if text =~ /\[title\](.*?)(\[meaning\]|\z)/m
        title = Regexp.last_match(1).strip
      end

      if text =~ /\[meaning\](.*?)(\[example\]|\z)/m
        meaning = Regexp.last_match(1).strip
      end

      if text =~ /\[example\](.*)/m
        example = Regexp.last_match(1).strip
      end

      { title: title, meaning: meaning, example: example }
    end
  end
end

const url = `/ai/generate_proverbword1=${encodeURIComponent(word1)}&word2=${encodeURIComponent(word2)}`
const es  = new EventSource(url)

このurlは、サーバー上の**/ai/generate_proverb**というエンドポイントを指しており、word1とword2という2つのパラメータをエンコードしてクエリ文字列として含めています。

*エンドポイントとは、クライアントがサーバーに特定の処理を依頼するための専用のURLです。

new EventSource(url)で指定したurlに対してSSE接続の確立をします。
↓
↓

routes.rbにある get :generate_proverb, to: "proverbs#generate"にいき、proverbsコントローラーのgenerateメソッドを呼びます。
↓
↓

include ActionController::Live

ActionController::Liveをインクルードすることで、サーバーからクライアントに対してデータをSSE形式で返すことができます。

*SSE形式とは、データをchunk(小さなかたまり)に分けて、時間差で継続的に送り続ける形式です。

response.headers["Content-Type"] = "text/event-stream"

ここでクライアントに対して、あなたにはSSE形式でレスポンスをするよという明示をしています。
宣言フェーズになります。
↓
↓

def self.stream(word1:, word2:)
      client  = OpenAI::Client.new
      prompt  = build_prompt(word1: word1, word2: word2)
      buffer  = +""

      client.chat(
        parameters: {
          model: "gpt-4o-mini",
          messages: [ { role: "user", content: prompt } ],
          temperature: 1.0,
          stream: proc do |chunk, _bytes|
            delta = chunk.dig("choices", 0, "delta", "content")
            next if delta.nil?

            buffer << delta

            sections = parse_sections(buffer)
            # 現在までに生成されたテキスト(まだ途中)を送る
            yield :chunk, sections if block_given?
          end
        }
      )
      # 全てのテキストを送る
      final_sections = parse_sections(buffer)
      yield :final, final_sections if block_given?
      final_sections
end

Ruby には文字列が凍結される(freeze される)仕組みがあります。
通常の空文字 "" は変更可能ですが、環境設定や Ruby の最適化によって
リテラル文字列が凍結される場合があります。

ストリーミング処理のように、
どんどん文字を追加して内容を変えていく必要がある場合、
凍結された文字列だとエラーになります。

その問題を避けるために、以下を使います。

buffer  = +""

必ず「変更可能な新しい String オブジェクト」を生成し、
安全に buffer << delta で積み上げていくことができます。

stream: proc do |chunk, _bytes|

stream: procを指定することでOpenAI API が chunk を返すたびに proc が呼ばれるようになります。

chunk には、OpenAI APIがストリーミングで返す部分レスポンスを Ruby の Hash にパースしたオブジェクトが入ります。その中の "choices"[0]["delta"]["content"] が“今回の差分文字(delta)”なので、dig を使ってその文字だけを取り出しています。

_bytesには生の文字列が入ります。以下のような感じです。"data: {"choices":[{"delta":{"content":"寿"}}]}"

if text =~ /\[title\](.*?)(\[meaning\]|\z)/m
        title = Regexp.last_match(1).strip
end

Regexp.last_match(1) は正規表現でカッコ ( ) に囲んだ部分を取り出すメソッドなので、openAIから送られる文字の[title]と[meaning]間の文字だけを取り出すことができます

yield :chunk, sections if block_given?

yeildメソッドを使って、呼び出し元のブロックを呼んでいます。

ブロックとは、呼び出し元のdo-endで囲んだ部分です。
↓
↓
また処理が戻ります。

 data = {
          event:   "chunk",
    title: payload[:title],
    meaning: payload[:meaning],
    example: payload[:example]
        }
response.stream.write("data: #{data.to_json}\n\n")

このdataには{title : "私は", }みたいなRubyのハッシュをJSON形式に変換して代入しています。response.stream.writeでSSE形式にしてクライアントに送信しています。
送信フェーズになります。

es.onmessage = (event) => {
const data = JSON.parse(event.data)

es.onmessage = (event) => {} の意味

Rails が response.stream.write("data: xxx\n\n") と送信すると

ブラウザはその「xxx」を event.data として受け取ることができます。

JSON.parse(event.data)

これでJSON形式で送られてきた値をここでJS形式に変換して、dataに代入します。

esを簡単にいうと、サーバーから流れてくるストリームデータ(SSE)を受け取るための特別なオブジェクト

最後に

サーバーやクライアントが行ったり来たりするので、フローの流れを理解するのが難しかったです。SSE接続のメリットなどもっと明確にしたい部分があるので、勉強していきたいと思います。
キャッチアップの仕方は、初めてのやり方でやったので、すごく楽しくて勉強になりました。


参考にさせて頂いた記事

https://railsguides.jp/action_controller_overview.html?utm_source=chatgpt.com

https://pragmaticpineapple.com/using-server-sent-events-to-stream-data-in-rails/


今回のキャッチアップの仕方はこの記事を参考にしながらしました。

https://qiita.com/TJ_droid/items/8784bc851d0abdf91472

Discussion