💬

Phoenix LiveViewでチャット画面を作ってChatGPTと会話してみた

2023/03/13に公開

はじめに

ChatGPTのAPIが公開されたので触ってみたい...」
Phoenixのv1.7がリリースされたので書き心地を試してみたい...」
LiveViewも使ってみたい...」
じゃあ一緒にやってしまおう!という記事。

LiveViewを使ってチャット画面を実装し、ChatGPTと会話してみるまでの過程を書きました。

(もし間違いなどありましたらコメントやTwitterなどで教えていただけると有難いです。)

全体のソースコード

環境

  • WSL2 (Ubuntu 22.04.1 LTS)
  • asdf v0.11.2
  • erlang v25.2.3
  • elixir v1.14.3
  • sqlite3 v3.37.2

プロジェクト立ち上げ

  1. ディレクトリ作成 & 設定
$ mkdir chatgpt-demo; cd $_
$ asdf local erlang 25.2.3
$ asdf local elixir 1.14.3-otp-25
  1. hexとphoenixのinstall(要 Elixir 1.12, Erlang 22以降)
$ mix local.hex
$ mix archive.install hex phx_new
  1. phoenixプロジェクト作成
$ mix phx.new . --app chatgpt_demo --database sqlite3
  1. 一回立ち上げてみる
$ mix setup
$ mix phx.server


localhost:4000の画面
スタート画面も一新されてなんかオシャレになってる...!

チャットページ作成

とりあえずチャットできるページを作る。
メッセージを送信できるフォームを作って、送信したメッセージを表示するまでやってみる。

schema

ChatGPTと1対1でのメッセージのやり取りができれば良いのでmessagesテーブルがあれば良さそう。
OpenAIのAPIリファレンスを見てみると、APIのparamsにあるmessagesrolecontentを保持しているらしい。
rolesystem, user, assistantのいずれかを値に持っていて、contentは文章とのこと。
なのでschemaをこんな感じに作る。

$ mix phx.gen.schema Chat.Message messages role:enum:system:user:assistant content:text 
* creating lib/chatgpt_demo/chat/message.ex
* creating priv/repo/migrations/20230311055317_create_messages.exs

$ mix ecto.migrate
lib/chatgpt_demo/chat/message.ex
  # 生成されたファイルの一部分
  schema "messages" do
    field :content, :string
    field :role, Ecto.Enum, values: [:system, :user, :assistant]

    timestamps()
  end

migrateするとrolecontentをカラムに持つmessagesテーブルができる。

ページの準備

/chatにページを作るためにlib/chatgpt_demo_web/router.exに加筆。

lib/chatgpt_demo_web/router.ex
 scope "/", ChatgptDemoWeb do
    pipe_through :browser

    get "/", PageController, :home
+   live "/chat", ChatLive
  end

lib/chatgpt_demo_web/にliveディレクトリを作成し、そこにChatLiveモジュールを作成。

lib/chatgpt_demo_web/live/chat_live.ex
defmodule ChatgptDemoWeb.ChatLive do
  use ChatgptWeb, :live_view
  # mount/3
  # handle_event/3
  # ...
end

ここに、初期化するためのmount/3関数や指定した値が変化したときの処理を定義するhandle_event/3関数などを書いていく。
また、ページ描画用のrender/1関数もあるが、同階層chatgpt_demo_web/live/chat_live.html.heexを作成することでも代用してくれるのでそっちを使う。

初期化

まずはmount/3
役割としてはフロント側で利用する変数を初期化してsocketに入れるという感じ。

lib/chatgpt_demo_web/live/chat_live.ex

  alias ChatgptDemo.Repo # 追加
  alias ChatgptDemo.Chat.Message # 追加
  
  ...
  
  def mount(_sesstion, _params, socket) do
    messages = Repo.all(Message)

    socket =
      socket
      |> assign(:form, to_form(%{}))
      |> stream(:messages, messages)

    {:ok, socket}
  end

使用する変数

  • :form: ユーザが入力する文章が入る (値はPhoenix.HTML.Form)
  • :messages: 対話履歴

基本はassign/3を使い、やり取りしたい値をsocketに入れる。第3引数は初期値。

phoenix v1.7からはstream/4が追加され、listを簡単に扱えるようになった。
特に、listの中身が:idフィールドを持ってるstructかmapだと設定要らずで楽。
:idを持ってない場合は:dom_idを指定する必要がある。(おそらく)
フロントエンド側からは@streams.messagesで値を取れる。
stream/4の詳細はドキュメント参照)

UI

chatgpt_demo_web/live/chat_live.html.heexファイルを作る。

lib/chatgpt_demo_web/live/chat_live.html.heex
<div class="flex flex-col max-w-4xl min-h-screen items-center">
    <h1 class="text-2xl">ChatGPT Demo</h1>
    <!-- 入力フォーム -->
    <.simple_form class="w-full" for={@form} id="send-message" phx-submit="submit">
        <.input field={@form[:content]} />
        <:actions>
            <.button><.icon name="hero-paper-airplane-solid" /></.button>
        </:actions>
    </.simple_form>
    <!-- メッセージ表示部分 -->
    <div class="mt-4 text-ms" id="messages", phx-update="stream">
    <%= for {message_id, message} <- @streams.messages do %>
        <%= case message.role do %>
            <% :user -> %>
                <p class="mt-2" id={message_id}><span class="font-semibold">User:</span> <%= message.content%></p>
            <% :assistant -> %>
                <p class="mt-2" id={message_id}><span class="font-semibold">ChatGPT:</span> <%= message.content%></p>
        <% end %>
    <% end %>
    </div>
    <!-- リセットボタン -->
    <div class="mt-6">
        <.button phx-click="reset"><.icon name="hero-archive-box-x-mark" /></.button>
    </div>
</div>

見出しと入力部分、送信ボタン、メッセージ表示部分、リセットボタンを置いてみた。

liveview初心者なので<.simple_form>の記述方法に戸惑ったが、liveviewにおけるComponentとのことで、ドキュメントなどを読みつつ実装。simple_formのid名はおそらく任意。phx-submit="submit"と指定することでボタンを押すと文字列"submit"に対応したhandle_event/3が呼び出される。inputの@form[:content]:contentも任意の名前を付けてよいが、変数名はサーバー側と対応付ける必要がある。

@streams.valuesの値をfor文で回すときは{id, value}の形で受ける必要がある。

確認

ここまで書いたらmix phx.serverで立ち上げて/chatに飛んでUIを確認できる。


localhost:4000/chatの画面

いい感じ。

イベント処理

このままだとボタンを押したときにエラーが出るだけなので、メッセージが送信された時とリセットボタンを押したときの処理を書く。

メッセージが送信されたときの処理

メッセージが送信されたときの流れとしては

  1. メッセージを受け取りDBに保存
  2. ChatGPTとやり取り(非同期)
  3. ページに反映

を想定している。このうち13を先に実装する。2は後ほど。

lib/chatgpt_demo_web/live/chat_live.ex
  require Logger # 追加
  
  ...
  
  def handle_event("submit", %{"content" => content}, socket) do
    case insert_message(%{role: :user, content: content}) do
      {:ok, message} ->
        {:noreply, stream_insert(socket, :messages, message)}

      {:error, changeset} ->
	Logger.error(inspect(changeset))
        {:noreply, socket}
    end
  end
  
  defp insert_message(params) do
    %Message{}
    |> Message.changeset(params)
    |> Repo.insert()
  end

フォームにphx-submit="submit"と書いたので、送信されたときの処理はhandle_event("submit", _, _)に記述する。また、inputコンポーネントにfield={@form[:content]}と指定したので、handle_event/3の第二引数には"content"をkeyに持つmapが渡される。

ChatgptDemo.Repo.insert/1changesetを渡すことでDBにメッセージを保存する。

メッセージを対話履歴に追加するためにstream_insert/4を利用する。
追加するとその変更を感知して自動でページに反映してくれる。便利。

リセットボタンが押されたときの処理

文脈を考慮した会話をさせるために対話履歴を全てChatGPTに投げる予定なので、関係ない対話履歴がずっと残っていると困る。そのためリセットボタンを用意する。今回は極力シンプルな実装にしているので、押すとDB内も含めて対話履歴が全部消える仕様にしている。

ボタンにphx-click="reset"を仕込んだのでhandle_event("reset", _, _)が呼ばれる。

lib/chatgpt_demo_web/live/chat_live.ex
  def handle_event("reset", _params, socket) do
    Repo.delete_all(Message)
    {:noreply, push_navigate(socket, to: "/chat")}
  end

Repo.delete_all/1でDBに保存されたメッセージをすべて削除 -> push_navigate/2でページ更新して対話履歴を消している。
今のところ(2023年3月13日現在)、streamの値を一括で消す関数がないのでページ更新で対処。

これで送信したメッセージが残るようになり、履歴のリセットもできるようになった。


送信したメッセージが表示されているlocalhost:4000/chatの画面

ChatGPTと会話できるようにする

これだけだと一人で虚無に語りかけているだけなので、ChatGPTと会話できるように修正する。

API

APIに関しては非公式のOpenAI APIのwrapperがあるので有難く使わせていただく。
depsにopenaiを追加して、Configにapi_keyを追加するだけで利用可能。

mix.exs
def deps do
  [
    ... ,
    {:openai, "~> 0.3.1"} # 追加
  ]
end
config/config.exs
config :openai,
  api_key: System.get_env("OPENAI_API_KEY"),
  http_options: [recv_timeout: 30_000] # timeoutオプション、任意

api_keyは環境変数経由で読み込むようにする。
筆者はOPENAI_API_KEY=~と書いた.envファイルを作成し、export $(shell cat .env | grep -v '^\s*#') && mix phx.serverで起動するようにした。この方法を採用した場合は.gitignoreファイルに.envを追加するのを忘れずに。

api_keyの取得方法はOpenAIのアカウントを作って https://platform.openai.com/account/api-keys で作成する。18$分は無料で使える。ありがとうOpenAI。

ChatGPTからメッセージを受け取る

処理の流れについておさらいすると、ユーザーからメッセージが送信された後は

  1. メッセージを受け取りDBに保存
  2. ChatGPTとやり取り(非同期)
  3. ページに反映

といった流れにする予定だった。ここではまだ実装していなかった2の処理を書いていく。

まずは2の処理を行う関数を呼び出そう。
handle_event("submit", _, _)関数内のメッセージをDBに保存する処理の後にsend/2を追加する。

lib/chatgpt_demo_web/live/chat_live.ex
  def handle_event("submit", %{"content" => content}, socket) do
    case insert_message(%{role: :user, content: content}) do
      {:ok, message} ->
+       send(self(), :chat_completion)
        {:noreply, stream_insert(socket, :messages, message)}

      {:error, changeset} ->
        Logger.error(inspect(changeset))
        {:noreply, socket}
    end
  end

send/2を用いて自分に対して:chat_completionを送るとhandle_info(:chat_completion, _)が非同期的に呼ばれる。非同期なのでhandle_info/2関数の実行を待たずに処理が進み、ユーザーのメッセージだけ先にページに反映してこの関数は終わる。ChatGPTとのやり取りに関することはhandle_info(:chat_completion, _)が担当する。

ということで、後はhandle_info(:chat_completion, _)でChatGPTとやりとりする処理(メッセージの保存なども含む)を書けば完了。

lib/chatgpt_demo_web/live/chat_live.ex
  def handle_info(:chat_completion, socket) do
    with(
      {:ok, chatgpt_reply} <- get_chatgpt_reply(),
      {:ok, message} <- insert_message(%{role: :assistant, content: chatgpt_reply})
    ) do
      {:noreply, stream_insert(socket, :messages, message)}
    else
      err ->
        Logger.error(inspect(err))
        {:noreply, socket}
    end
  end
  
  defp get_chatgpt_reply() do
    messages =
      Message
      |> Repo.all()
      |> Enum.map(fn msg -> %{role: msg.role, content: msg.content} end)
      |> List.insert_at(0, init_chatgpt_prompt())
    
    case OpenAI.chat_completion(model: "gpt-3.5-turbo", messages: messages) do
      {:ok, res} ->
        Logger.debug(res)
        %{"message" => %{"content" => content}} = hd(res.choices)
        {:ok, content}
      err ->
        {:error, err}
    end
  end

  defp init_chatgpt_prompt() do
    %{
      role: "system",
      content: """
      あなたはお嬢様としてロールプレイを行います。お嬢様になりきってください。一人称は「わたくし」です。語尾に「ですわ」と付くことが多いです。
      """
    }
  end

handle_info(:chat_completion, _)は、ChatGPTにメッセージを送信し、返信を貰ってそれをDBに保存、これらの処理が成功したら画面に反映という処理内容。
こういう処理をwith/1で書くとネストにならずに済む。便利。

ChatGPTへの送受信はget_chatgpt_reply/0が対応している。
処理の中核となるのはOpenAI.chat_completion/1。パラメータにはmodelmessagesを指定してあげればよい。
modelにはChatGPTの中身である"gpt-3.5-turbo"を指定。
messagesにはフォーマットに合うように変換した対話履歴を指定する。

また、ChatGPTにキャラクター性を持たせるためにmessagesの先頭に設定プロンプトも追加している。プロンプトの形式は他のメッセージと同じ。roleには:systemを指定してあげて、contentに設定を書く。設定を書く用の入力ボックスなどは用意していないので直書き。
今回はお嬢様になってもらいました。

リクエストとレスポンスの仕様やその他パラメータはopenaiライブラリのREADMEAPIのドキュメント参照。

DBの保存と画面への反映はユーザーのメッセージで行なった処理と同様。違いがあるとしたらinsert_message/1に渡す値をrole: :assistantにしているくらい。

実際に会話してみた

さて、ようやく会話できるようになったのでお嬢様と会話してみよう。

会話例1


会話例1: 優雅なお嬢様

100点!お嬢様っぽい!ちゃんとマルチターン対話できてえらい!

会話例2


会話例2: 格ゲーは嗜まれますか?

格ゲーは嗜まないらしい。

会話例3


会話例3: elixirについて教えてください

elixirに対する造詣が深いお嬢様...!

おわり

LiveViewでチャットアプリを実装してChatGPTとお話ししてみました。
シンプル実装なので実用的なチャットアプリには程遠いですが、ちゃんと動くものを作れて満足です。

一応全体のコードをgithubに上げておきました。
本記事では省きましたが、ChatGPTが文を生成している間に新しい文をsubmitされると困りそうなので、それを防くような処理を追加しています。

参考

GitHubで編集を提案

Discussion