GPT-4Vを使って画像入力対応のSlackボットを作った

# 概要
2023年11月7日の深夜(日本時間)に開催されたOpenAI DevDayにてGPT-4Vが発表されました。
これにより、Web API経由でGPT-4に画像を入力することができます。
そこで、弊社に導入しているSlackボットに画像入力機能を追加しました。
バックエンドにはGASを使用しています。実装はすんなりいかず、結構苦労しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/05790f89-411c-f54d-df0f-e2c1010e554a.png)
※自動車用信号機が赤なので横断歩道は渡ってもいいと思うのだが...

# 料金
画像の解像度によって料金は変わってくるようです。
大体一枚あたり、1~2円で、低解像度設定にすれば0.1円くらいになります。

計算方法が結構複雑なので細かい説明は割愛します。
単純に解像度に比例するわけではなく、アスペクト比が重要です。

例えば、フルHDなら結構高くて$0.01105です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/a8a23760-3598-1354-b2b3-6be9e24c6214.png)

対して、4000 * 4000pxならむしろ安くなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/02e7b71a-9cb5-eddc-3721-57345cdf5b3d.png)

以下の公式サイトから計算できます。
https://openai.com/pricing

計算式ロジックは以下のページに記載があります
https://platform.openai.com/docs/guides/vision/calculating-costs

# 作り方
## 1. テキスト入力に対応したボットを作る
以下の記事を参照してGPT-4に対応したボットを作ります
https://qiita.com/noritsune/items/17c20dccb0eb00f2622e

## 2. ボットの権限追加
ボットスコープの`file:read`権限を追加します。
以降の手順実装するファイルをDLする処理に必要です。
追加後は再インストールを忘れずに。

## 3. 使用する言語モデルの変更
使用する言語モデルを`gpt-4-vision-preview`に変更します。

## 4. 応答するメッセージイベントの追加
これまでは`handleMessageEvt`メソッド内の以下のif文でテキストメッセージ以外を無視していました。
しかし、画像添付のメッセージにはsubtypeが付いてきます。
これを無視するわけにはいかないので以下のように修正します

```diff
function handleMessageEvt(evt) {
- if (evt.subtype != null) {
+ if (evt.subtype && evt.subtype !== "file_share") {
```

## 5. メッセージ内の画像をbase64エンコードする
メッセージに付いている画像をそのままGPTに渡すことはできません。
メッセージに含まれる画像のURLはどれも認証なしではDL出来ない為です。

一応、`files.sharedPublicURL`APIを使用すれば認証なしで画像をDLするためのURLを取得できます。
しかし、このAPIはボットをインストールしたユーザー以外がアップロードした画像には非対応です。

そこで、一旦画像をDLしてそれをbase64エンコードしてGPTに渡すことにしました。
ボットトークンで認証すればprivateなURLからでも画像をDLできるのは目から鱗でした。
また、GASのUtilitiesクラスにはbase64エンコードをしてくれる関数があるのでわざわざ自分で書く必要が無くて楽ちんでした。

```javascript
function fetchBase64ImageFromSlack(privateDlUrl) {
 const token = getSlackBotToken();
 const res = UrlFetchApp.fetch(privateDlUrl, {
   headers: {
     Authorization: `Bearer ${token}`
   }
 });
 const blob = res.getBlob();
 return Utilities.base64Encode(blob.getBytes());
}
```

## 6. プロンプトを画像対応
これまではボットに対するSlackのメッセージを全てテキストプロンプトに変換してGPTに投げていました。
これを、メッセージ内に画像があれば画像もプロンプトに含む様に変更します。
`parseSlackMsgsToChatGPTQuesryMsgs`メソッドを以下の様に大幅変更しました。

```javascript
function parseSlackMsgsToChatGPTQuesryMsgs(slackMsgs) {
  return slackMsgs.map(msg => {
    const content = [{
      type: "text",
      text: trimMentionText(msg.text)
    }];

    if (msg.files) {
      const imageContents = msg.files
        // filesにはpdfなども含まれる
        .filter(file => file.mimetype.includes("image"))
        .map(file => {
          const base64Image = fetchBase64ImageFromSlack(file.url_private_download);
          return {
            type: "image_url",
            // APIでpublicな画像のURLを取得できないのでprivateなものをDLしてbase64エンコードして渡す
            // リクエストボディが大きくなってしまうのできればURLで渡したい
            image_url: {
              url: `data:${file.mimetype}};base64,${base64Image}`
            }
          }
        });
      content.push(...imageContents);
    }

    return {
      role: msg.user == BOT_USER_ID ? "assistant" : "user",
      content: content
    }
  });
}
```

# まとめ
イレギュラーなこともありましたがなんとか動くようになりました！
この機能を使って業務中の息抜きをするのが楽しみです。業務に役立つかは追々探っていきます。

# 参考リンク
[GPT-4Vの公式ガイド](https://platform.openai.com/docs/guides/vision)
[Slackから画像をDLする方法でとても参考になった記事](https://sey323log.hatenablog.com/entry/20221106/1667720656)
[files.sharedPublicURL APIの公式リファレンス](https://api.slack.com/methods/files.sharedPublicURL)