Phoenix LiveViewでチャット画面を作ってChatGPTと会話してみた
はじめに
「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
プロジェクト立ち上げ
- ディレクトリ作成 & 設定
$ mkdir chatgpt-demo; cd $_
$ asdf local erlang 25.2.3
$ asdf local elixir 1.14.3-otp-25
- hexとphoenixのinstall(要 Elixir 1.12, Erlang 22以降)
$ mix local.hex
$ mix archive.install hex phx_new
- phoenixプロジェクト作成
$ mix phx.new . --app chatgpt_demo --database sqlite3
- 一回立ち上げてみる
$ mix setup
$ mix phx.server
localhost:4000の画面
スタート画面も一新されてなんかオシャレになってる...!
チャットページ作成
とりあえずチャットできるページを作る。
メッセージを送信できるフォームを作って、送信したメッセージを表示するまでやってみる。
schema
ChatGPTと1対1でのメッセージのやり取りができれば良いのでmessagesテーブルがあれば良さそう。
OpenAIのAPIリファレンスを見てみると、APIのparamsにあるmessages
はrole
とcontent
を保持しているらしい。
role
はsystem, 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
# 生成されたファイルの一部分
schema "messages" do
field :content, :string
field :role, Ecto.Enum, values: [:system, :user, :assistant]
timestamps()
end
migrateするとrole
、content
をカラムに持つmessages
テーブルができる。
ページの準備
/chatにページを作るために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モジュールを作成。
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に入れるという感じ。
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
ファイルを作る。
<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の画面
いい感じ。
イベント処理
このままだとボタンを押したときにエラーが出るだけなので、メッセージが送信された時とリセットボタンを押したときの処理を書く。
メッセージが送信されたときの処理
メッセージが送信されたときの流れとしては
- メッセージを受け取りDBに保存
- ChatGPTとやり取り(非同期)
- ページに反映
を想定している。このうち1と3を先に実装する。2は後ほど。
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/1
にchangesetを渡すことでDBにメッセージを保存する。
メッセージを対話履歴に追加するためにstream_insert/4
を利用する。
追加するとその変更を感知して自動でページに反映してくれる。便利。
リセットボタンが押されたときの処理
文脈を考慮した会話をさせるために対話履歴を全てChatGPTに投げる予定なので、関係ない対話履歴がずっと残っていると困る。そのためリセットボタンを用意する。今回は極力シンプルな実装にしているので、押すとDB内も含めて対話履歴が全部消える仕様にしている。
ボタンにphx-click="reset"
を仕込んだのでhandle_event("reset", _, _)
が呼ばれる。
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を追加するだけで利用可能。
def deps do
[
... ,
{:openai, "~> 0.3.1"} # 追加
]
end
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からメッセージを受け取る
処理の流れについておさらいすると、ユーザーからメッセージが送信された後は
- メッセージを受け取りDBに保存
- ChatGPTとやり取り(非同期)
- ページに反映
といった流れにする予定だった。ここではまだ実装していなかった2の処理を書いていく。
まずは2の処理を行う関数を呼び出そう。
handle_event("submit", _, _)
関数内のメッセージをDBに保存する処理の後にsend/2
を追加する。
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とやりとりする処理(メッセージの保存なども含む)を書けば完了。
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
。パラメータにはmodel
とmessages
を指定してあげればよい。
model
にはChatGPTの中身である"gpt-3.5-turbo"を指定。
messages
にはフォーマットに合うように変換した対話履歴を指定する。
また、ChatGPTにキャラクター性を持たせるためにmessages
の先頭に設定プロンプトも追加している。プロンプトの形式は他のメッセージと同じ。role
には:system
を指定してあげて、content
に設定を書く。設定を書く用の入力ボックスなどは用意していないので直書き。
今回はお嬢様になってもらいました。
リクエストとレスポンスの仕様やその他パラメータはopenaiライブラリのREADMEやAPIのドキュメント参照。
DBの保存と画面への反映はユーザーのメッセージで行なった処理と同様。違いがあるとしたらinsert_message/1
に渡す値をrole: :assistant
にしているくらい。
実際に会話してみた
さて、ようやく会話できるようになったのでお嬢様と会話してみよう。
会話例1
会話例1: 優雅なお嬢様
100点!お嬢様っぽい!ちゃんとマルチターン対話できてえらい!
会話例2
会話例2: 格ゲーは嗜まれますか?
格ゲーは嗜まないらしい。
会話例3
会話例3: elixirについて教えてください
elixirに対する造詣が深いお嬢様...!
おわり
LiveViewでチャットアプリを実装してChatGPTとお話ししてみました。
シンプル実装なので実用的なチャットアプリには程遠いですが、ちゃんと動くものを作れて満足です。
一応全体のコードをgithubに上げておきました。
本記事では省きましたが、ChatGPTが文を生成している間に新しい文をsubmitされると困りそうなので、それを防くような処理を追加しています。
Discussion