【Elixir/Phoenix】LiveViewありがとう！はじめてでもChatGPTのWeb画面みたいなの作れたよ。

# はじめに

[LivebookでChatGPIのAPIを使ってみて](https://qiita.com/GeekMasahiro/items/20894bac55f77c962a21)、使い方は理解できました。
要するに、メッセージのまとまりを渡すと、次のメッセージが返ってくる。
メッセージを編集できるUIがあったら便利そうなのでPhoenixの勉強も兼ねて作ってみます。

# プロジェクト作成

DBサーバを用意するのが面倒だったのでsqlite3にします。

```bash
$ mix phx.new --database sqlite3 mygpt
$ cd mygpt
$ mix ecto.create
$ mix phx.server
```

ブラウザーで表示を確認してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532235/1d31cddf-0de7-f152-2e32-28360244abf1.png)

# phx.gen.liveでメッセージの編集画面を作成

メッセージは、ChatGPTのAPI仕様とあわせて、contentとroleの値を持たせることにします。
roleの値は整数とします。APIを呼び出すときのroleとの対応を決めておきます。

|API呼び出し時の値|roleの値|
|---------------|--------|
|system|0|
|user|1|
|assistant|2|

phx.gen.liveでDBと、liveviewを追加します。

```bash
$ mix phx.gen.live Chatlog Message messages role:integer content:string
```

router.exの書き換え
```diff_elixir
     pipe_through :browser
 
     get "/", PageController, :home
+
+    live "/messages", MessageLive.Index, :index
+    live "/messages/new", MessageLive.Index, :new
+    live "/messages/:id/edit", MessageLive.Index, :edit
+
+    live "/messages/:id", MessageLive.Show, :show
+    live "/messages/:id/show/edit", MessageLive.Show, :edit
   end
 
   # Other scopes may use custom stacks.
```

```
$ mix ecto.migrate
```

メッセージの追加削除編集ができる事を確認してみます。
`http://localhost:4000/messages`を開くとメッセージの編集ができます。ChatGPTに問い合わせる内容をsystem, userのロールそれぞれで入力してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532235/64d78296-018b-a9c4-721f-80f4258a6c8e.png)

ここまでが一瞬でできるのがいいすね。
いい感じで編集もできるので、テンションも上がってきます。
あとはAPI呼び出しするだけ。

# ChatGPTのAPI呼び出しを追加する

## 環境設定等

openaiのAPIを呼び出すモジュールを追加

```diff_elixir:/mix.exs
       {:telemetry_poller, "~> 1.0"},
       {:gettext, "~> 0.20"},
       {:jason, "~> 1.2"},
-      {:plug_cowboy, "~> 2.5"}
+      {:plug_cowboy, "~> 2.5"},
+      {:openai, "~> 0.3.1"}
     ]
   end
 ```

openaiのconfigを行う。
- APIキーを設定する処理を追加
- タイムアウトを延ばす（openaiの応答に時間がかかるので必須）

```diff_elixir:/config/config.exs
 # Use Jason for JSON parsing in Phoenix
 config :phoenix, :json_library, Jason
 
+config :openai,
+  api_key: System.get_env("LB_OPENAI_API_KEY"),
+  # optional, passed to [HTTPoison.Request](https://hexdocs.pm/httpoison/HTTPoison.Request.html) options
+  http_options: [recv_timeout: 30_000]
+
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
```

APIキーは環境変数LB_OPENAI_API_KEYに設定しておきます。
私は、WSLの.bashrcに次のように記述して追加しています。
```
export LB_OPENAI_API_KEY=*******************************************
```

## API呼び出しのロジック追加

'/lib/mygpt/chat_gpt.ex'にChatGPTのAPIを呼び出すロジックを追加します。

Chatlogに保存されてるすべてのメッセージを'Chatlog.list_messages()'で取り出してChatGPTのAPIを呼び出します。roleの値を変換する処理がありますが、messages:にそのまま渡すだけ。

```elixir
    messages =
      Chatlog.list_messages()
      |> Enum.map(fn %{content: content, role: role_num} ->
        %{content: content, role: to_str(role_num)}
      end)

    {:ok, result} =
      OpenAI.chat_completion(
        model: "gpt-3.5-turbo",
        messages: messages
      )
      |> IO.inspect(label: "api result")
```

呼び出した後、ChatGPTからの応答を、'Chatlog.create_message(new_message)'でChatlogに追加しています。
```elixir
    message = hd(result.choices)["message"]

    new_message = %{
      content: message["content"],
      role: to_num(message["role"])
    }

    Chatlog.create_message(new_message)
```

chat_gpt.ex全体はこれ
```elixir:/lib/mygpt/chat_gpt.ex
defmodule Mygpt.ChatGpt do
  alias Mygpt.Chatlog

  def callapi() do
    messages =
      Chatlog.list_messages()
      |> Enum.map(fn %{content: content, role: role_num} ->
        %{content: content, role: to_str(role_num)}
      end)

    {:ok, result} =
      OpenAI.chat_completion(
        model: "gpt-3.5-turbo",
        messages: messages
      )
      |> IO.inspect(label: "api result")

    message = hd(result.choices)["message"]

    new_message = %{
      content: message["content"],
      role: to_num(message["role"])
    }

    Chatlog.create_message(new_message)
  end

  def to_num(str) do
    case str do
      "system" -> 0
      "user" -> 1
      "assistant" -> 2
    end
  end

  def to_str(role_num) do
    case role_num do
      0 -> "system"
      1 -> "user"
      2 -> "assistant"
    end
  end
end
```

## 送信ボタン追加

```diff_elixir:/lib/mygpt_web/live/message_live/index.html.heex
     </.link>
   </:action>
 </.table>
+<.button phx-click="send">Send</.button>
 
 <.modal :if={@live_action in [:new, :edit]} id="message-modal" show on_cancel={JS.patch(~p"/messages")}>
   <.live_component
```

## 送信ボタンのハンドラー追加

ここが今回のプロラムで、一番苦労したところは、ここでした。

最初は、Sendボタンを押したら'Mygpt.ChatGpt.callapi()'を呼び出すだけにしていました。
これだと、ボタンを押すと、APIが呼び出せれ、DBにChatGPTからのメッセージが追加されます。
しかし、画面には追加したメッセージが表示されません。ページリロードすると表示されます。
新規のメッセージを追加するときの処理を真似して、`stream_insert()`使って、ChatGPTからのメッセージを画面にも追加するようにしました。


```diff_elixir:/lib/mygpt_web/live/message_live/index.ex
 
     {:noreply, stream_delete(socket, :messages, message)}
   end
+
+  def handle_event("send", _, socket) do
+    {:ok, message} = Mygpt.ChatGpt.callapi()
+
+    {:noreply, stream_insert(socket, :messages, message)}
+  end
 end
```

# 実行
```
$ mix deps.get
$ mix phx.server
```

送信ボタンを押すと、ChatGPTからのメッセージ「なにか食べたい？」が追加されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532235/6ce27fa8-f0ce-b5c4-85a5-baccb25c3762.png)


子供のメッセージを追加して、再び、Sendしてみます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532235/ca2d07ee-2c86-7c09-6d31-f84959015be8.png)

会話ができました！

# まとめ
- phx.gen.liveでメッセージの編集画面がサクッとつくれてテンションが上がった
- stream_insert()の使い方が勉強になった
- ChatGPIのWeb画面にある会話のヒストリー機能も勢いで作れそう
- もうちょっとUIを改善したら普段使いできるんじゃないか
- Elixirは楽しい

ソースコードここです

https://github.com/masahiro-999/mygpt