Streamlitがついに画像・ドキュメント添付に対応！Claude 3.7 Sonnetと連携したマルチモーダルチャットの作り方

気がついたら、Streamlitのチャット入力が。添付ファイルに対応していました！
1.43.0からっぽいです。

https://github.com/streamlit/streamlit/releases/tag/1.43.0

出たらやるしかない！Bedrockの画像添付とドキュメント添付で実際に試しました！

## これをもとに機能追加していきます

```python
import boto3
import streamlit as st

MODEL_ID = "us.anthropic.claude-3-haiku-20240307-v1:0"

st.title("Bedrock Chat")

# messagesをセッションに保存
if "messages" not in st.session_state:
    st.session_state.messages = []
messages = st.session_state.messages

# 会話のやり取りを表示
for message in messages:
    with st.chat_message(message["role"]):
        for content in message["content"]:
            if "text" in content:
                st.write(content["text"])

# ユーザー入力
if prompt := st.chat_input():
    # ユーザー入力の表示
    with st.chat_message("user"):
        st.write(prompt)

    # Converse APIに送信するメッセージを作成
    user_message = {"role": "user", "content": [{"text": prompt}]}

    messages.append(user_message)

    # Converse API呼び出し
    client = boto3.client("bedrock-runtime")
    response = client.converse(modelId=MODEL_ID, messages=messages)

    assistant_message = response["output"]["message"]
    messages.append(assistant_message)

    # Bedrockの返答を表示
    with st.chat_message("assistant"):
        st.write(assistant_message["content"][0]["text"])
```

## 画像のインプットに対応する

`st.chat_input()`の`accept_file`を指定すると、ファイルの添付が可能になります。さらに、`file_type`で対応するファイルの形式を指定できます。

BedrockのConverse APIが対応している画像フォーマットに絞って添付できるようにします。

```diff_python
+ IMAGE_FORMAT = ["png", "jpeg", "gif", "webp"]

- if prompt := st.chat_input():
+ if prompt := st.chat_input(accept_file="multiple", file_type=IMAGE_FORMAT):
```

画面はこのように変わります。

* accept_file指定なし
 
    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/411bcea8-cf4e-4bcf-bc9b-09d67c82367a.png)

* accept_file指定あり
 
    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/8b5200c7-93f7-440b-9d93-ea4bd6d282e9.png)

ファイル添付を有効にすると`prompt`の型が変わります。ユーザー入力のテキストは`prompt.text`に変更なります。

```diff_python
    with st.chat_message("user"):
-         st.write(prompt)
+         st.write(prompt.text)

-   user_message = {"role": "user", "content": [{"text": prompt}]}
+   user_message = {"role": "user", "content": [{"text": prompt.text}]}
```

アップロードしたファイルは`prompt.files`で取得できます。

```diff_python
for file in prompt.files:
    file.name # ファイル名
    file.type # ファイルのMimeType
    file.getvalue() # ファイルのバイナリ
```

`st.file_uploader`のドキュメントが参考になります。

https://docs.streamlit.io/develop/api-reference/widgets/st.file_uploader

画面へ表示する際は`file`を`st.image()`に渡すだけでOKです。

```diff_python
    with st.chat_message("user"):
        st.write(prompt.text)

+       for file in prompt.files:
+           st.image(file)
```

添付された画像をConverse APIへ送信するmessagesに追加します。

`file.type`にはMIMEタイプがセットされているので、`/`以降を切り取って`format`に指定します。
（例：「image/jpeg」→「jpeg」）

:::note
細かい点ですが、「jpeg」はOKですが「jpg」はバリデーションエラーになります。（大文字もだめ）
そのため、ファイル名のから拡張子を取るよりMIMEタイプの後ろを取ったほうが良さそうです
:::

```diff_python
+   for file in prompt.files:
+       user_message["content"].append(
+           {
+               "image": {
+                   "format": file.type.split("/")[1],
+                   "source": {"bytes": file.getvalue()},
+               }
+           }
+       )
```

これで画像の添付ができるようになりました。

ソースの全体はこちらです。

```diff_python
import boto3
import streamlit as st

MODEL_ID = "us.anthropic.claude-3-7-sonnet-20250219-v1:0"

st.title("Bedrock Chat")

# 対応画像フォーマット
IMAGE_FORMAT = ["png", "jpeg", "gif", "webp"]

# messagesをセッションに保存
if "messages" not in st.session_state:
    st.session_state.messages = []
messages = st.session_state.messages

# 会話のやり取りを表示
for message in messages:
    with st.chat_message(message["role"]):
        for content in message["content"]:
            if "text" in content:
                st.write(content["text"])
            elif "image" in content:
                st.image(content["image"]["source"]["bytes"])

# ユーザー入力
if prompt := st.chat_input(accept_file="multiple", file_type=IMAGE_FORMAT):
    # ユーザー入力の表示
    with st.chat_message("user"):
        st.write(prompt.text)

        for file in prompt.files:
            st.image(file)

    # Converse APIに送信するメッセージを作成
    user_message = {"role": "user", "content": [{"text": prompt.text}]}

    for file in prompt.files:
        user_message["content"].append(
            {
                "image": {
                    "format": file.type.split("/")[1],
                    "source": {"bytes": file.getvalue()},
                }
            }
        )

    messages.append(user_message)

    # Converse API呼び出し
    client = boto3.client("bedrock-runtime")
    response = client.converse(modelId=MODEL_ID, messages=messages)

    assistant_message = response["output"]["message"]
    messages.append(assistant_message)

    # Bedrockの返答を表示
    with st.chat_message("assistant"):
        st.write(assistant_message["content"][0]["text"])
```

## ドキュメントのインプットに対応する

BedrockのConverse APIは画像だけでなく、PDFなどのファイルの入力にも対応しています。画像の入力と組み合わせることも可能です。

ドキュメントが対応しているフォーマットを定義し、`st.chat_input`の`file_type`に追加します。

```diff_python
+ # 対応ドキュメントフォーマット
+ DOCUMENT_FORMAT = ["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"]
```

```diff_python
- if prompt := st.chat_input(accept_file="multiple", file_type=IMAGE_FORMAT):
+ if prompt := st.chat_input(
+     accept_file="multiple", file_type=IMAGE_FORMAT + DOCUMENT_FORMAT
+ ):
```

ドキュメント部分のコンテンツをConverse APIへ送信するmessagesに追加します。
画像（image）と異なり、formatは拡張子を取得します。また、documentの場合は`name`属性があります。この`name`は入力規則があり、日本語文字が指定できません（バリデーションエラーになります）。そのため、uuidで生成するようにしました。

```diff_python
    for file in prompt.files:
+       if (file_format := f.type.split("/")[1]) in IMAGE_FORMAT:
            user_message["content"].append(
                {
                    "image": {
                        "format": file_format,
                        "source": {"bytes": file.getvalue()},
                    }
                }
            )
+        elif (ext := os.path.splitext(f.name)[1][1:]) in DOCUMENT_FORMAT:
+            user_message["content"].append(
+                {
+                    "document": {
+                        "format": ext,
+                        "name": str(uuid.uuid4()),
+                        "source": {"bytes": file.getvalue()},
+                    }
+                }
+            )
```

最後に画面に表示する部分を追加します。ファイル名ぐらいしか出せなですが。

```diff_python
 # 会話のやり取りを表示
 for message in messages:
     with st.chat_message(message["role"]):
         for content in message["content"]:
             if "text" in content:
                 st.write(content["text"])
             elif "image" in content:
                 st.image(content["image"]["source"]["bytes"])
+            elif "document" in content:
+                st.write(content["document"]["name"])
```

```diff_python
    # ユーザー入力の表示
    with st.chat_message("user"):
        st.write(prompt.text)

        for file in prompt.files:
            if file.type.split("/")[1] in IMAGE_FORMAT:
                st.image(file)
+            elif os.path.splitext(f.name)[1][1:] in DOCUMENT_FORMAT:
+                st.write(file.name)
```

できました。

```python
import os
import uuid

import boto3
import streamlit as st

MODEL_ID = "us.anthropic.claude-3-7-sonnet-20250219-v1:0"

st.title("Bedrock Chat")

# 対応画像フォーマット
IMAGE_FORMAT = ["png", "jpeg", "gif", "webp"]
# 対応ドキュメントフォーマット
DOCUMENT_FORMAT = ["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"]

# messagesをセッションに保存
if "messages" not in st.session_state:
    st.session_state.messages = []
messages = st.session_state.messages

# 会話のやり取りを表示
for message in messages:
    with st.chat_message(message["role"]):
        for content in message["content"]:
            if "text" in content:
                st.write(content["text"])
            elif "image" in content:
                st.image(content["image"]["source"]["bytes"])
            elif "document" in content:
                st.write(content["document"]["name"])

# ユーザー入力
if prompt := st.chat_input(
    accept_file="multiple", file_type=IMAGE_FORMAT + DOCUMENT_FORMAT
):
    # ユーザー入力の表示
    with st.chat_message("user"):
        st.write(prompt.text)

        for file in prompt.files:
            if file.type.split("/")[1] in IMAGE_FORMAT:
                st.image(file)
            elif os.path.splitext(f.name)[1][1:] in DOCUMENT_FORMAT:
                st.write(file.name)

    # Converse APIに送信するメッセージを作成
    user_message = {"role": "user", "content": [{"text": prompt.text}]}

    for file in prompt.files:
        if (file_format := f.type.split("/")[1]) in IMAGE_FORMAT:
            user_message["content"].append(
                {
                    "image": {
                        "format": file_format,
                        "source": {"bytes": file.getvalue()},
                    }
                }
            )
        elif (ext := os.path.splitext(f.name)[1][1:]) in DOCUMENT_FORMAT:
            user_message["content"].append(
                {
                    "document": {
                        "format": ext,
                        "name": str(uuid.uuid4()),
                        "source": {"bytes": file.getvalue()},
                    }
                }
            )

    messages.append(user_message)

    # Converse API呼び出し
    client = boto3.client("bedrock-runtime")
    response = client.converse(modelId=MODEL_ID, messages=messages)

    assistant_message = response["output"]["message"]
    messages.append(assistant_message)

    # Bedrockの返答を表示
    with st.chat_message("assistant"):
        st.write(assistant_message["content"][0]["text"])
```

---

どうぞご活用ください！